mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94f7701155 | ||
|
|
ba68e2599b | ||
|
|
7631505a79 | ||
|
|
ed308bc64c | ||
|
|
a72d5214ce | ||
|
|
dea127bfe5 | ||
|
|
1f21974a76 | ||
|
|
ef9140df01 | ||
|
|
27f5896ebe | ||
|
|
3bd193b5ba | ||
|
|
7dd6ddef53 | ||
|
|
6197336f60 | ||
|
|
d97a5ec036 | ||
|
|
13adfee3a4 | ||
|
|
5387a16bb6 | ||
|
|
6d7848059e | ||
|
|
a324ac5d71 |
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
|
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
|
||||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { test } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import {
|
||||||
|
loadRealDaemonState,
|
||||||
|
injectDesktopBridge,
|
||||||
|
openDesktopSettings,
|
||||||
|
expectUpdateBanner,
|
||||||
|
clickInstallUpdate,
|
||||||
|
expectInstallInProgress,
|
||||||
|
interceptDaemonManagementConfirmDialog,
|
||||||
|
toggleDaemonManagement,
|
||||||
|
expectDaemonManagementConfirmDialog,
|
||||||
|
expectDaemonManagementEnabled,
|
||||||
|
expectDaemonManagementDisabled,
|
||||||
|
expectDaemonStatusPid,
|
||||||
|
expectDaemonStatusLogPath,
|
||||||
|
expectDaemonStatusVersion,
|
||||||
|
} from "./helpers/desktop-updates";
|
||||||
|
|
||||||
|
function getSeededServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Playwright Electron runner exists; we simulate the desktop bridge via
|
||||||
|
// addInitScript so Electron-gated UI activates without a real Electron process.
|
||||||
|
test.describe("Desktop updates", () => {
|
||||||
|
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId: getSeededServerId(),
|
||||||
|
updateAvailable: true,
|
||||||
|
latestVersion: "1.2.3",
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
|
||||||
|
await expectUpdateBanner(page, "1.2.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking install shows the installing state on the callout", async ({ page }) => {
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId: getSeededServerId(),
|
||||||
|
updateAvailable: true,
|
||||||
|
latestVersion: "1.2.3",
|
||||||
|
slowInstall: true,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
|
||||||
|
await expectUpdateBanner(page, "1.2.3");
|
||||||
|
await clickInstallUpdate(page);
|
||||||
|
await expectInstallInProgress(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Desktop daemon management", () => {
|
||||||
|
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId,
|
||||||
|
manageBuiltInDaemon: true,
|
||||||
|
confirmShouldAccept: false,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openDesktopSettings(page, serverId);
|
||||||
|
|
||||||
|
const dialogArgs = await interceptDaemonManagementConfirmDialog(page);
|
||||||
|
expectDaemonManagementConfirmDialog(dialogArgs);
|
||||||
|
|
||||||
|
await expectDaemonManagementEnabled(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId,
|
||||||
|
manageBuiltInDaemon: true,
|
||||||
|
confirmShouldAccept: false,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openDesktopSettings(page, serverId);
|
||||||
|
|
||||||
|
await expectDaemonManagementEnabled(page);
|
||||||
|
await toggleDaemonManagement(page, "disable");
|
||||||
|
await expectDaemonManagementEnabled(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId,
|
||||||
|
manageBuiltInDaemon: true,
|
||||||
|
confirmShouldAccept: true,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openDesktopSettings(page, serverId);
|
||||||
|
|
||||||
|
await toggleDaemonManagement(page, "disable");
|
||||||
|
|
||||||
|
await expectDaemonManagementDisabled(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
const realState = await loadRealDaemonState();
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId,
|
||||||
|
manageBuiltInDaemon: false,
|
||||||
|
daemonPid: realState.pid,
|
||||||
|
daemonVersion: realState.version,
|
||||||
|
daemonLogPath: realState.logPath,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openDesktopSettings(page, serverId);
|
||||||
|
|
||||||
|
await expectDaemonStatusVersion(page, realState.version);
|
||||||
|
await expectDaemonStatusPid(page, realState.pid);
|
||||||
|
await expectDaemonStatusLogPath(page, realState.logPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
const realState = await loadRealDaemonState();
|
||||||
|
await injectDesktopBridge(page, {
|
||||||
|
serverId,
|
||||||
|
manageBuiltInDaemon: true,
|
||||||
|
daemonPid: realState.pid,
|
||||||
|
daemonVersion: realState.version,
|
||||||
|
daemonLogPath: realState.logPath,
|
||||||
|
confirmShouldAccept: true,
|
||||||
|
});
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openDesktopSettings(page, serverId);
|
||||||
|
|
||||||
|
await expectDaemonStatusPid(page, realState.pid);
|
||||||
|
|
||||||
|
await toggleDaemonManagement(page, "disable");
|
||||||
|
await expectDaemonManagementDisabled(page);
|
||||||
|
await expectDaemonStatusPid(page, null);
|
||||||
|
|
||||||
|
await toggleDaemonManagement(page, "enable");
|
||||||
|
await expectDaemonManagementEnabled(page);
|
||||||
|
const newPid = realState.pid !== null ? realState.pid + 1000 : 11000;
|
||||||
|
await expectDaemonStatusPid(page, newPid);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test";
|
import { test as base, expect, type Page } from "@playwright/test";
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||||
|
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||||
|
|
||||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||||
// reliably for every test that uses this `test` object.
|
// reliably for every test that uses this `test` object.
|
||||||
const test = base.extend<{ paseoE2ESetup: void }>({
|
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||||
baseURL: async ({}, provide) => {
|
baseURL: async ({}, provide) => {
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
@@ -102,6 +103,11 @@ const test = base.extend<{ paseoE2ESetup: void }>({
|
|||||||
},
|
},
|
||||||
{ auto: true },
|
{ auto: true },
|
||||||
],
|
],
|
||||||
|
withWorkspace: async ({ page }, provide) => {
|
||||||
|
const handle = createWithWorkspace(page);
|
||||||
|
await provide(handle.withWorkspace);
|
||||||
|
await handle.cleanup();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export { test, expect, type Page };
|
export { test, expect, type Page };
|
||||||
|
|||||||
@@ -705,6 +705,7 @@ export default async function globalSetup() {
|
|||||||
process.env.E2E_SERVER_ID = offer.serverId;
|
process.env.E2E_SERVER_ID = offer.serverId;
|
||||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||||
process.env.E2E_METRO_PORT = String(metroPort);
|
process.env.E2E_METRO_PORT = String(metroPort);
|
||||||
|
process.env.E2E_PASEO_HOME = paseoHome;
|
||||||
console.log(
|
console.log(
|
||||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
|
||||||
|
const messages = page.getByTestId("assistant-message");
|
||||||
|
const target = hasText === undefined ? messages.first() : messages.filter({ hasText }).first();
|
||||||
|
await expect(target).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function awaitToolCall(page: Page, toolName: string | RegExp): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("tool-call-badge").filter({ hasText: toolName }).first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
|
||||||
|
}
|
||||||
+19
-294
@@ -1,217 +1,36 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getE2EDaemonPort(): string {
|
|
||||||
const port = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!port) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
if (port === "6767") {
|
|
||||||
throw new Error(
|
|
||||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
|
||||||
const port = getE2EDaemonPort();
|
|
||||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
|
||||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!expectedServerId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
|
|
||||||
const needsReset = await page.evaluate(
|
|
||||||
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
|
|
||||||
const raw = localStorage.getItem("@paseo:daemon-registry");
|
|
||||||
if (!raw) return true;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
|
||||||
const entry = parsed[0] as { serverId?: string; connections?: unknown };
|
|
||||||
if (entry?.serverId !== serverId) return true;
|
|
||||||
const connections = entry?.connections;
|
|
||||||
if (!Array.isArray(connections)) return true;
|
|
||||||
if (
|
|
||||||
connections.some(
|
|
||||||
(c: { type?: string; endpoint?: string }) =>
|
|
||||||
c?.type === "directTcp" &&
|
|
||||||
typeof c?.endpoint === "string" &&
|
|
||||||
/:6767\b/.test(c.endpoint),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
return !connections.some(
|
|
||||||
(c: { type?: string; endpoint?: string }) =>
|
|
||||||
c?.type === "directTcp" && c?.endpoint === endpoint,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ expectedEndpoint, expectedServerId },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!needsReset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
const daemon = buildSeededHost({
|
|
||||||
serverId: expectedServerId,
|
|
||||||
endpoint: expectedEndpoint,
|
|
||||||
nowIso,
|
|
||||||
});
|
|
||||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
|
||||||
await page.evaluate(
|
|
||||||
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
|
||||||
localStorage.setItem("@paseo:e2e", "1");
|
|
||||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
|
||||||
localStorage.removeItem("@paseo:settings");
|
|
||||||
},
|
|
||||||
{ daemon, preferences },
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
|
|
||||||
let registry: unknown;
|
|
||||||
try {
|
|
||||||
registry = JSON.parse(registryRaw);
|
|
||||||
} catch {
|
|
||||||
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
|
|
||||||
}
|
|
||||||
if (!Array.isArray(registry) || registry.length !== 1) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const daemon = registry[0] as { serverId?: string; connections?: unknown };
|
|
||||||
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { serverId: daemon.serverId, connections: daemon.connections };
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
|
|
||||||
if (
|
|
||||||
!Array.isArray(connections) ||
|
|
||||||
!connections.some(
|
|
||||||
(c: { type?: string; endpoint?: string }) =>
|
|
||||||
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
Array.isArray(connections) &&
|
|
||||||
connections.some(
|
|
||||||
(c: { type?: string; endpoint?: string }) =>
|
|
||||||
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
|
|
||||||
try {
|
|
||||||
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
|
||||||
if (prefs?.serverId !== serverId) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) throw error;
|
|
||||||
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
|
|
||||||
cause: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
|
||||||
const port = getE2EDaemonPort();
|
|
||||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
|
||||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!expectedServerId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = await page.evaluate(() => {
|
|
||||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
|
||||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
|
||||||
return { registryRaw, prefsRaw };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!snapshot.registryRaw) {
|
|
||||||
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
|
|
||||||
if (serverId !== expectedServerId) {
|
|
||||||
throw new Error(
|
|
||||||
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assertDaemonConnections(connections, expectedEndpoint);
|
|
||||||
|
|
||||||
if (!snapshot.prefsRaw) {
|
|
||||||
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
|
||||||
}
|
|
||||||
assertPreferencesMatch(snapshot.prefsRaw, serverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const gotoAppShell = async (page: Page) => {
|
export const gotoAppShell = async (page: Page) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await ensureE2EStorageSeeded(page);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const gotoHome = async (page: Page) => {
|
export const gotoHome = async (page: Page) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
if (
|
const entryButton = page
|
||||||
!(await composer
|
.getByText("Add a project", { exact: true })
|
||||||
.first()
|
.or(page.getByText("Add project", { exact: true }))
|
||||||
.isVisible()
|
.or(page.getByText("New agent", { exact: true }))
|
||||||
.catch(() => false))
|
.first();
|
||||||
) {
|
|
||||||
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
|
|
||||||
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
|
|
||||||
const newAgentButton = page.getByText("New agent", { exact: true }).first();
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () =>
|
async () =>
|
||||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
(await composer.isVisible().catch(() => false)) ||
|
||||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
(await entryButton.isVisible().catch(() => false)),
|
||||||
(await newAgentButton.isVisible().catch(() => false)),
|
{ timeout: 10_000 },
|
||||||
{ timeout: 10000 },
|
)
|
||||||
)
|
.toBe(true);
|
||||||
.toBe(true);
|
|
||||||
|
|
||||||
if (await addProjectCta.isVisible().catch(() => false)) {
|
if (!(await composer.isVisible().catch(() => false))) {
|
||||||
await addProjectCta.click();
|
await entryButton.click();
|
||||||
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
|
||||||
await addProjectSidebar.click();
|
|
||||||
} else {
|
|
||||||
await newAgentButton.click();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
|
||||||
|
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const openSettings = async (page: Page) => {
|
export const openSettings = async (page: Page) => {
|
||||||
@@ -341,46 +160,6 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ensureHostSelected = async (page: Page) => {
|
export const ensureHostSelected = async (page: Page) => {
|
||||||
await ensureE2EStorageSeeded(page);
|
|
||||||
|
|
||||||
// Absolute verification that we're using the per-run e2e daemon (never :6767).
|
|
||||||
// Also self-heal a rare case where app code rewrites daemon IDs after boot, by
|
|
||||||
// realigning create-agent-preferences.serverId to the sole seeded daemon.
|
|
||||||
try {
|
|
||||||
await assertE2EUsesSeededTestDaemon(page);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (!/create-agent-preferences\.serverId/i.test(message)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fix = await page.evaluate(() => {
|
|
||||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
|
||||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
|
||||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
|
||||||
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
|
|
||||||
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
|
||||||
if (!Array.isArray(registry) || registry.length !== 1)
|
|
||||||
return { ok: false, reason: "registry shape" } as const;
|
|
||||||
const serverId = registry[0]?.serverId;
|
|
||||||
if (typeof serverId !== "string" || serverId.length === 0)
|
|
||||||
return { ok: false, reason: "missing serverId" } as const;
|
|
||||||
prefs.serverId = serverId;
|
|
||||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
|
|
||||||
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
|
||||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
|
|
||||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
|
||||||
return { ok: true } as const;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fix.ok) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
await assertE2EUsesSeededTestDaemon(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
@@ -392,7 +171,7 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
if (await selectHostLabel.isVisible()) {
|
if (await selectHostLabel.isVisible()) {
|
||||||
await selectHostLabel.click();
|
await selectHostLabel.click();
|
||||||
|
|
||||||
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
// We enforce a single seeded daemon, so the option should be unambiguous.
|
||||||
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
||||||
const daemonIdOption = page
|
const daemonIdOption = page
|
||||||
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
||||||
@@ -657,57 +436,3 @@ export const createAgentInRepo = async (
|
|||||||
await setWorkingDirectory(page, config.directory);
|
await setWorkingDirectory(page, config.directory);
|
||||||
await createAgent(page, config.prompt);
|
await createAgent(page, config.prompt);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
|
||||||
const promptText = page.getByTestId("permission-request-question").first();
|
|
||||||
await expect(promptText).toBeVisible({ timeout });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const allowPermission = async (page: Page) => {
|
|
||||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
|
||||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
|
||||||
await acceptButton.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const denyPermission = async (page: Page) => {
|
|
||||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
|
||||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
|
||||||
await denyButton.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
|
||||||
// Wait for the stop button to disappear
|
|
||||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
|
||||||
|
|
||||||
// First, let's debug what's happening - wait a bit to see the state
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Check if stop button is visible
|
|
||||||
const isVisible = await stopButton.isVisible().catch(() => false);
|
|
||||||
|
|
||||||
if (isVisible) {
|
|
||||||
// If stop button is still visible after permission denial,
|
|
||||||
// it might be that the agent is waiting for something.
|
|
||||||
// Let's check if there's a tool call result or other UI indication
|
|
||||||
|
|
||||||
// Look for any indication that the agent has processed the permission denial
|
|
||||||
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
|
||||||
|
|
||||||
// Wait for the tool call result to appear
|
|
||||||
await expect(toolCallResult)
|
|
||||||
.toBeVisible({ timeout: 10000 })
|
|
||||||
.catch(() => {
|
|
||||||
// If no specific message, just wait for the button to disappear
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now wait for the stop button to disappear
|
|
||||||
await expect(stopButton).not.toBeVisible({ timeout });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getToolCallCount(page: Page): Promise<number> {
|
|
||||||
// Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc.
|
|
||||||
// They appear as pressable badges in the agent stream
|
|
||||||
const toolCallBadges = page.locator('[data-testid="tool-call-badge"]');
|
|
||||||
return toolCallBadges.count();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
function composerInput(page: Page) {
|
||||||
|
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composerLocator(page: Page) {
|
||||||
|
return composerInput(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectComposerVisible(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||||
|
const input = composerInput(page);
|
||||||
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
|
await input.fill(text);
|
||||||
|
await input.press("Enter");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelAgent(page: Page): Promise<void> {
|
||||||
|
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||||
|
await expect(stopButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await stopButton.click();
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { openSettings } from "./app";
|
||||||
|
import { openSettingsHost } from "./settings";
|
||||||
|
|
||||||
|
interface DaemonApiStatus {
|
||||||
|
version: string;
|
||||||
|
serverId: string;
|
||||||
|
hostname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PidFileContent {
|
||||||
|
pid: number;
|
||||||
|
desktopManaged: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealDaemonState {
|
||||||
|
version: string;
|
||||||
|
pid: number | null;
|
||||||
|
logPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads live state from the running E2E test daemon: version from the HTTP
|
||||||
|
* status endpoint, PID from the paseo.pid lock file, log path from the
|
||||||
|
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
|
||||||
|
*/
|
||||||
|
export async function loadRealDaemonState(): Promise<RealDaemonState> {
|
||||||
|
const port = process.env.E2E_DAEMON_PORT;
|
||||||
|
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||||
|
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
||||||
|
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
|
||||||
|
|
||||||
|
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
|
||||||
|
const data = (await resp.json()) as DaemonApiStatus;
|
||||||
|
|
||||||
|
let pid: number | null = null;
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
|
||||||
|
pid = (JSON.parse(raw) as PidFileContent).pid ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
// PID file may not be present yet on a very fresh daemon start
|
||||||
|
console.warn("[desktop-updates] paseo.pid not found:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopBridgeConfig {
|
||||||
|
serverId: string;
|
||||||
|
updateAvailable?: boolean;
|
||||||
|
latestVersion?: string;
|
||||||
|
slowInstall?: boolean;
|
||||||
|
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
||||||
|
daemonPid?: number | null;
|
||||||
|
daemonVersion?: string | null;
|
||||||
|
daemonLogPath?: string;
|
||||||
|
/** Initial manageBuiltInDaemon setting. Defaults to false. */
|
||||||
|
manageBuiltInDaemon?: boolean;
|
||||||
|
/**
|
||||||
|
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||||
|
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||||
|
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||||
|
*/
|
||||||
|
confirmShouldAccept?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmDialogCall {
|
||||||
|
message: string;
|
||||||
|
title: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects window.paseoDesktop before app load so all Electron-gated code
|
||||||
|
* activates. The update-check IPC is mocked at the boundary so the real
|
||||||
|
* auto-updater never fires. Daemon start/stop commands are stateful: the mock
|
||||||
|
* tracks running state and assigns a fresh PID on each start, letting tests
|
||||||
|
* observe PID changes without touching the real E2E daemon process.
|
||||||
|
* dialog.ask captures call arguments on window.__capturedDialogCall so tests
|
||||||
|
* can assert dialog copy without depending on window.confirm concatenation.
|
||||||
|
*/
|
||||||
|
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
|
||||||
|
await page.addInitScript((cfg) => {
|
||||||
|
// Mutable state shared across IPC calls within this page
|
||||||
|
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
|
||||||
|
let daemonRunning = true;
|
||||||
|
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||||
|
let startCount = 0;
|
||||||
|
|
||||||
|
function buildDaemonStatus() {
|
||||||
|
return {
|
||||||
|
serverId: cfg.serverId,
|
||||||
|
status: daemonRunning ? "running" : "stopped",
|
||||||
|
listen: null,
|
||||||
|
hostname: null,
|
||||||
|
pid: currentPid,
|
||||||
|
home: "",
|
||||||
|
version: cfg.daemonVersion ?? null,
|
||||||
|
desktopManaged: manageDaemon,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||||
|
platform: "darwin",
|
||||||
|
invoke: async (command: string, args?: Record<string, unknown>) => {
|
||||||
|
if (command === "check_app_update") {
|
||||||
|
return cfg.updateAvailable
|
||||||
|
? {
|
||||||
|
hasUpdate: true,
|
||||||
|
readyToInstall: true,
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
latestVersion: cfg.latestVersion ?? "1.2.3",
|
||||||
|
body: null,
|
||||||
|
date: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
hasUpdate: false,
|
||||||
|
readyToInstall: false,
|
||||||
|
currentVersion: "1.0.0",
|
||||||
|
latestVersion: null,
|
||||||
|
body: null,
|
||||||
|
date: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "install_app_update") {
|
||||||
|
if (cfg.slowInstall) {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
version: cfg.latestVersion ?? "1.2.3",
|
||||||
|
message: "App update installed. Restart required.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "desktop_daemon_status") {
|
||||||
|
return buildDaemonStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "desktop_daemon_logs") {
|
||||||
|
return { logPath: cfg.daemonLogPath ?? "", contents: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "get_desktop_settings") {
|
||||||
|
return {
|
||||||
|
releaseChannel: "stable",
|
||||||
|
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "patch_desktop_settings") {
|
||||||
|
const patchDaemon =
|
||||||
|
args?.daemon && typeof args.daemon === "object"
|
||||||
|
? (args.daemon as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
if (typeof patchDaemon.manageBuiltInDaemon === "boolean") {
|
||||||
|
manageDaemon = patchDaemon.manageBuiltInDaemon;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
releaseChannel: "stable",
|
||||||
|
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "stop_desktop_daemon") {
|
||||||
|
daemonRunning = false;
|
||||||
|
currentPid = null;
|
||||||
|
return buildDaemonStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "start_desktop_daemon") {
|
||||||
|
startCount += 1;
|
||||||
|
daemonRunning = true;
|
||||||
|
// First start (bootstrap) returns the configured PID; subsequent starts
|
||||||
|
// (after a stop) get a fresh PID so tests can observe the change.
|
||||||
|
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
|
||||||
|
return buildDaemonStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
ask: async (message: string, options?: Record<string, unknown>) => {
|
||||||
|
(window as unknown as Record<string, unknown>).__capturedDialogCall = {
|
||||||
|
message,
|
||||||
|
title: options?.title,
|
||||||
|
};
|
||||||
|
return cfg.confirmShouldAccept ?? false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getPendingOpenProject: async () => null,
|
||||||
|
events: { on: async () => () => undefined },
|
||||||
|
};
|
||||||
|
}, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||||
|
await openSettings(page);
|
||||||
|
await openSettingsHost(page, serverId);
|
||||||
|
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
||||||
|
const callout = page.getByTestId("update-callout");
|
||||||
|
await expect(callout).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickInstallUpdate(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Install & restart" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectInstallInProgress(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Installing..." })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clicks the daemon management switch and waits for dialog.ask to fire in the
|
||||||
|
* mock, then returns the captured call args (message + title). The mock auto-
|
||||||
|
* dismisses via confirmShouldAccept=false so callers can assert copy without
|
||||||
|
* worrying about state changes.
|
||||||
|
*/
|
||||||
|
export async function interceptDaemonManagementConfirmDialog(
|
||||||
|
page: Page,
|
||||||
|
): Promise<ConfirmDialogCall> {
|
||||||
|
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !!(window as unknown as Record<string, unknown>).__capturedDialogCall,
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
);
|
||||||
|
return page.evaluate(
|
||||||
|
() => (window as unknown as Record<string, unknown>).__capturedDialogCall as ConfirmDialogCall,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleDaemonManagement(
|
||||||
|
page: Page,
|
||||||
|
_action: "enable" | "disable",
|
||||||
|
): Promise<void> {
|
||||||
|
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void {
|
||||||
|
expect(args.title).toBe("Pause built-in daemon");
|
||||||
|
expect(args.message).toContain("stop the built-in daemon immediately");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDaemonManagementEnabled(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).toBeChecked();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDaemonManagementDisabled(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).not.toBeChecked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the daemon status card shows the given PID. Pass null to assert
|
||||||
|
* the cleared state (shown as "PID —" when the daemon is stopped).
|
||||||
|
*/
|
||||||
|
export async function expectDaemonStatusPid(page: Page, pid: number | null): Promise<void> {
|
||||||
|
const expected = pid !== null ? `PID ${pid}` : "PID —";
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("host-page-daemon-lifecycle-card").getByText(expected),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDaemonStatusLogPath(page: Page, logPath: string): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("host-page-daemon-lifecycle-card").getByText(logPath),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the host page identity badge shows the given version string.
|
||||||
|
* The badge is populated from the live WebSocket session's serverInfo.version.
|
||||||
|
*/
|
||||||
|
export async function expectDaemonStatusVersion(page: Page, version: string): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("host-page-identity").getByText(version, { exact: false }),
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
@@ -68,20 +68,6 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
|||||||
|
|
||||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
|
||||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
|
||||||
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
|
||||||
await button.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
|
||||||
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
|
||||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
|
||||||
await button.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
|
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
|
||||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
@@ -90,11 +76,6 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
|||||||
|
|
||||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
|
|
||||||
export async function waitForLauncherPanel(_page: Page): Promise<void> {
|
|
||||||
// No-op: the launcher panel no longer exists.
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assert the new agent tab button is visible in the tab bar. */
|
/** Assert the new agent tab button is visible in the tab bar. */
|
||||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||||
await expect(
|
await expect(
|
||||||
@@ -119,7 +100,7 @@ export async function clickNewChat(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Click the new terminal button to create a terminal tab. */
|
/** Click the new terminal button to create a terminal tab. */
|
||||||
export async function clickTerminal(page: Page): Promise<void> {
|
export async function clickNewTerminal(page: Page): Promise<void> {
|
||||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
@@ -193,6 +174,19 @@ export async function sampleTabsDuringTransition(
|
|||||||
return snapshots;
|
return snapshots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function terminalSurfaceLocator(page: Page) {
|
||||||
|
return page.locator('[data-testid="terminal-surface"]').first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
|
||||||
|
const tabTestId = `workspace-tab-agent_${agentId}`;
|
||||||
|
await expect(page.getByTestId(tabTestId).filter({ visible: true })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(getActiveTabTestId(page)).resolves.toBe(tabTestId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Workspace setup ───────────────────────────────────────────────────────
|
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Create a temp git repo and return its path with a cleanup function. */
|
/** Create a temp git repo and return its path with a cleanup function. */
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export async function waitForPermissionPrompt(page: Page, timeout = 30_000): Promise<void> {
|
||||||
|
await expect(page.getByTestId("permission-request-question").first()).toBeVisible({ timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function allowPermission(page: Page): Promise<void> {
|
||||||
|
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||||
|
await expect(acceptButton).toBeVisible({ timeout: 5_000 });
|
||||||
|
await acceptButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function denyPermission(page: Page): Promise<void> {
|
||||||
|
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||||
|
await expect(denyButton).toBeVisible({ timeout: 5_000 });
|
||||||
|
await denyButton.click();
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { requireServerId } from "./sidebar";
|
||||||
|
|
||||||
|
function escapeRegex(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_LABELS = {
|
||||||
|
general: "General",
|
||||||
|
shortcuts: "Shortcuts",
|
||||||
|
integrations: "Integrations",
|
||||||
|
permissions: "Permissions",
|
||||||
|
diagnostics: "Diagnostics",
|
||||||
|
about: "About",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||||
|
|
||||||
|
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||||
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
|
await expect(sidebar).toBeVisible();
|
||||||
|
|
||||||
|
if (section === "projects") {
|
||||||
|
await page.getByTestId("settings-projects").click();
|
||||||
|
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||||
|
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||||
|
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openAddHostFlow(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("settings-add-host").click();
|
||||||
|
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectHostConnectionType(
|
||||||
|
page: Page,
|
||||||
|
type: "direct" | "relay",
|
||||||
|
): Promise<void> {
|
||||||
|
const label = type === "direct" ? "Direct connection" : "Paste pairing link";
|
||||||
|
await page.getByRole("button", { name: label }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleHostAdvanced(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openCompactSettings(page: Page): Promise<void> {
|
||||||
|
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||||
|
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||||
|
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||||
|
await expect(settingsButton).toBeVisible();
|
||||||
|
await settingsButton.click();
|
||||||
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectCompactSettingsList(page: Page): Promise<void> {
|
||||||
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
|
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSettingsSidebarHidden(page: Page): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSettingsSidebarSections(
|
||||||
|
page: Page,
|
||||||
|
sections: Array<Exclude<SettingsSection, "projects">>,
|
||||||
|
): Promise<void> {
|
||||||
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
|
for (const section of sections) {
|
||||||
|
await expect(
|
||||||
|
sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function goBackInSettings(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSettingsBackButton(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("settings-back-to-workspace").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
|
||||||
|
const serverId = requireServerId();
|
||||||
|
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||||
|
await expectHostSettingsUrl(page, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openCompactSettingsHost(page: Page): Promise<void> {
|
||||||
|
const serverId = requireServerId();
|
||||||
|
await openSettingsHost(page, serverId);
|
||||||
|
await expectHostSettingsUrl(page, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectAddHostMethodOptions(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fillDirectHostUri(page: Page, uri: string): Promise<void> {
|
||||||
|
await page.getByTestId("direct-host-uri-input").fill(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDirectHostFormValues(
|
||||||
|
page: Page,
|
||||||
|
fields: { host: string; port: string; password: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.getByTestId("direct-host-input")).toHaveValue(fields.host);
|
||||||
|
await expect(page.getByTestId("direct-port-input")).toHaveValue(fields.port);
|
||||||
|
await expect(page.getByTestId("direct-password-input")).toHaveValue(fields.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDirectHostUriHidden(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectDiagnosticsContent(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectAboutContent(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectGeneralContent(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickEditHostLabel(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("host-page-label-edit-button").click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel);
|
||||||
|
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
|
||||||
|
const card = page.getByTestId("host-page-connections-card");
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostInjectMcpCard(page: Page): Promise<void> {
|
||||||
|
const card = page.getByTestId("host-page-inject-mcp-card");
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostActionCards(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
|
||||||
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
|
await expect(sidebar).toBeVisible();
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||||
|
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||||
|
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
|
||||||
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
|
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
|
||||||
|
"data-testid",
|
||||||
|
`settings-host-entry-${serverId}`,
|
||||||
|
);
|
||||||
|
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
||||||
|
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
||||||
|
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
export function requireServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||||
|
const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceListed(page: Page, name: string): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
@@ -263,6 +263,34 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectTerminalSurfaceVisible(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid="terminal-surface"]').first()).toBeVisible({
|
||||||
|
timeout: options?.timeout ?? 20_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function focusTerminalSurface(page: Page): Promise<void> {
|
||||||
|
await expectTerminalSurfaceVisible(page);
|
||||||
|
await page.locator('[data-testid="terminal-surface"]').first().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function typeInTerminal(page: Page, text: string): Promise<void> {
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="terminal-surface"]')
|
||||||
|
.first()
|
||||||
|
.pressSequentially(text, { delay: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForTerminalAttached(page: Page): Promise<void> {
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="terminal-attach-loading"]')
|
||||||
|
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
export function computePercentile(samples: number[], p: number): number {
|
export function computePercentile(samples: number[], p: number): number {
|
||||||
const sorted = [...samples].sort((a, b) => a - b);
|
const sorted = [...samples].sort((a, b) => a - b);
|
||||||
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { waitForTabBar } from "./launcher";
|
||||||
|
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||||
|
import { createTempGitRepo } from "./workspace";
|
||||||
|
import {
|
||||||
|
connectWorkspaceSetupClient,
|
||||||
|
openHomeWithProject,
|
||||||
|
type WorkspaceSetupDaemonClient,
|
||||||
|
} from "./workspace-setup";
|
||||||
|
|
||||||
|
export interface CreatedWorkspace {
|
||||||
|
workspaceId: string;
|
||||||
|
repoPath: string;
|
||||||
|
navigateTo(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithWorkspaceOptions {
|
||||||
|
worktree?: boolean;
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WithWorkspace = (options?: WithWorkspaceOptions) => Promise<CreatedWorkspace>;
|
||||||
|
|
||||||
|
interface WorktreeRecord {
|
||||||
|
repoPath: string;
|
||||||
|
worktreePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithWorkspaceHandle {
|
||||||
|
withWorkspace: WithWorkspace;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||||
|
let client: WorkspaceSetupDaemonClient | null = null;
|
||||||
|
const repos: Array<{ cleanup: () => Promise<void> }> = [];
|
||||||
|
const worktrees: WorktreeRecord[] = [];
|
||||||
|
|
||||||
|
const withWorkspace: WithWorkspace = async (options) => {
|
||||||
|
if (!client) {
|
||||||
|
client = await connectWorkspaceSetupClient();
|
||||||
|
}
|
||||||
|
const prefix = options?.prefix ?? (options?.worktree ? "wt-" : "ws-");
|
||||||
|
const repo = await createTempGitRepo(prefix);
|
||||||
|
repos.push(repo);
|
||||||
|
|
||||||
|
let workspacePath = repo.path;
|
||||||
|
if (options?.worktree) {
|
||||||
|
const tempRoot = await realpath("/tmp");
|
||||||
|
workspacePath = path.join(
|
||||||
|
tempRoot,
|
||||||
|
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
const branchName = `paseo-wt-${Date.now()}`;
|
||||||
|
execSync(
|
||||||
|
`git worktree add ${JSON.stringify(workspacePath)} -b ${JSON.stringify(branchName)} main`,
|
||||||
|
{ cwd: repo.path, stdio: "ignore" },
|
||||||
|
);
|
||||||
|
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
|
||||||
|
// Register the parent project so the sidebar lists it before we navigate.
|
||||||
|
await client.openProject(repo.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opened = await client.openProject(workspacePath);
|
||||||
|
if (!opened.workspace) {
|
||||||
|
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
|
||||||
|
}
|
||||||
|
const workspaceId = opened.workspace.id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId,
|
||||||
|
repoPath: workspacePath,
|
||||||
|
navigateTo: async () => {
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await selectWorkspaceInSidebar(page, workspaceId);
|
||||||
|
await waitForTabBar(page);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
withWorkspace,
|
||||||
|
cleanup: async () => {
|
||||||
|
for (const { repoPath, worktreePath } of worktrees) {
|
||||||
|
try {
|
||||||
|
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup so the original test failure is preserved.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const repo of repos) {
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
if (client) {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { clickNewChat, clickTerminal } from "./launcher";
|
import { clickNewChat, clickNewTerminal } from "./launcher";
|
||||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||||
|
|
||||||
function terminalSurface(page: Page) {
|
function terminalSurface(page: Page) {
|
||||||
@@ -20,7 +20,7 @@ export async function expectTerminalCwd(page: Page, expectedPath: string): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
|
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
|
||||||
await clickTerminal(page);
|
await clickNewTerminal(page);
|
||||||
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
|
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { expect, type Page } from "@playwright/test";
|
|||||||
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
||||||
import { gotoAppShell } from "./app";
|
import { gotoAppShell } from "./app";
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
|
import { switchWorkspaceViaSidebar } from "./workspace-ui";
|
||||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||||
|
|
||||||
interface WorkspaceSetupDaemonClient {
|
interface WorkspaceSetupDaemonClient {
|
||||||
@@ -315,6 +316,30 @@ export async function fetchWorkspaceById(
|
|||||||
return workspace;
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function navigateToWorkspaceViaSidebar(
|
||||||
|
page: Page,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspaceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("workspace-scripts-button").click();
|
||||||
|
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startWorkspaceScriptFromMenu(page: Page, scriptName: string): Promise<void> {
|
||||||
|
await page.getByTestId(`workspace-scripts-start-${scriptName}`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("workspace-scripts-menu-backdrop").click();
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForWorkspaceSetupProgress(
|
export async function waitForWorkspaceSetupProgress(
|
||||||
client: WorkspaceSetupDaemonClient,
|
client: WorkspaceSetupDaemonClient,
|
||||||
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceTabsAbsent(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectNoTerminalTabs(page: Page): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickFirstTerminalTab(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
const tab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||||
|
await expect(tab).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||||
|
await tab.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectFirstTerminalTabContains(page: Page, text: string): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]').first()).toContainText(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function sampleWorkspaceTabIds(
|
export async function sampleWorkspaceTabIds(
|
||||||
page: Page,
|
page: Page,
|
||||||
options: { durationMs?: number; intervalMs?: number } = {},
|
options: { durationMs?: number; intervalMs?: number } = {},
|
||||||
|
|||||||
@@ -114,6 +114,49 @@ export async function expectWorkspaceHeader(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectReconnectingToastVisible(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
||||||
|
timeout: options?.timeout ?? 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectReconnectingToastGone(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
|
||||||
|
timeout: options?.timeout ?? 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostConnectingOrOffline(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
|
||||||
|
).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectMenuButtonVisible(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceHeaderAbsent(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceDeckEntryLocator(page: Page, serverId: string, workspaceId: string) {
|
||||||
|
return page.getByTestId(`workspace-deck-entry-${serverId}:${workspaceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceDeckEntryCount(page: Page, count: number): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(count);
|
||||||
|
}
|
||||||
|
|
||||||
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
||||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import {
|
|||||||
assertNewChatTileVisible,
|
assertNewChatTileVisible,
|
||||||
assertTerminalTileVisible,
|
assertTerminalTileVisible,
|
||||||
assertSingleNewTabButton,
|
assertSingleNewTabButton,
|
||||||
clickNewTabButton,
|
|
||||||
pressNewTabShortcut,
|
pressNewTabShortcut,
|
||||||
clickNewChat,
|
clickNewChat,
|
||||||
clickTerminal,
|
clickNewTerminal,
|
||||||
countTabsOfKind,
|
countTabsOfKind,
|
||||||
getTabTestIds,
|
getTabTestIds,
|
||||||
waitForTabWithTitle,
|
waitForTabWithTitle,
|
||||||
measureTileTransition,
|
measureTileTransition,
|
||||||
sampleTabsDuringTransition,
|
sampleTabsDuringTransition,
|
||||||
|
terminalSurfaceLocator,
|
||||||
} from "./helpers/launcher";
|
} from "./helpers/launcher";
|
||||||
|
import { expectComposerVisible, composerLocator } from "./helpers/composer";
|
||||||
|
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
|
||||||
import {
|
import {
|
||||||
connectTerminalClient,
|
connectTerminalClient,
|
||||||
setupDeterministicPrompt,
|
setupDeterministicPrompt,
|
||||||
@@ -50,9 +52,7 @@ test.describe("Tab creation", () => {
|
|||||||
|
|
||||||
await pressNewTabShortcut(page);
|
await pressNewTabShortcut(page);
|
||||||
|
|
||||||
// Should show the composer directly (no launcher panel)
|
await expectComposerVisible(page);
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
|
||||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
||||||
@@ -77,11 +77,9 @@ test.describe("Tab creation", () => {
|
|||||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
await clickNewTabButton(page);
|
await clickNewChat(page);
|
||||||
|
|
||||||
// Draft composer should appear (the agent message input)
|
await expectComposerVisible(page);
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
|
||||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
const tabsAfter = await getTabTestIds(page);
|
const tabsAfter = await getTabTestIds(page);
|
||||||
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||||
@@ -92,11 +90,9 @@ test.describe("Tab creation", () => {
|
|||||||
test.setTimeout(45_000);
|
test.setTimeout(45_000);
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
await clickTerminal(page);
|
await clickNewTerminal(page);
|
||||||
|
|
||||||
// Terminal surface should appear
|
await expectTerminalSurfaceVisible(page);
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
|
||||||
|
|
||||||
const tabsAfter = await getTabTestIds(page);
|
const tabsAfter = await getTabTestIds(page);
|
||||||
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||||
@@ -141,19 +137,18 @@ test.describe("Terminal title propagation", () => {
|
|||||||
try {
|
try {
|
||||||
// Navigate to workspace and open a terminal
|
// Navigate to workspace and open a terminal
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
await clickTerminal(page);
|
await clickNewTerminal(page);
|
||||||
|
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
await expectTerminalSurfaceVisible(page);
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
await terminalSurfaceLocator(page).click();
|
||||||
await terminal.first().click();
|
|
||||||
|
|
||||||
await setupDeterministicPrompt(page);
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
// Send OSC 0 (set window title) escape sequence
|
// Send OSC 0 (set window title) escape sequence
|
||||||
const testTitle = `E2E-Title-${Date.now()}`;
|
const testTitle = `E2E-Title-${Date.now()}`;
|
||||||
await terminal
|
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
|
||||||
.first()
|
delay: 0,
|
||||||
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
});
|
||||||
|
|
||||||
// Wait for the tab to reflect the new title
|
// Wait for the tab to reflect the new title
|
||||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||||
@@ -171,24 +166,24 @@ test.describe("Terminal title propagation", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
await clickTerminal(page);
|
await clickNewTerminal(page);
|
||||||
|
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
await expectTerminalSurfaceVisible(page);
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
await terminalSurfaceLocator(page).click();
|
||||||
await terminal.first().click();
|
|
||||||
|
|
||||||
await setupDeterministicPrompt(page);
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
// Fire many rapid title changes — only the last should stick
|
// Fire many rapid title changes — only the last should stick
|
||||||
const finalTitle = `Final-${Date.now()}`;
|
const finalTitle = `Final-${Date.now()}`;
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
await terminal
|
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
|
||||||
.first()
|
delay: 0,
|
||||||
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
});
|
||||||
}
|
}
|
||||||
await terminal
|
await terminalSurfaceLocator(page).pressSequentially(
|
||||||
.first()
|
`printf '\\033]0;${finalTitle}\\007'\n`,
|
||||||
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
{ delay: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
// The tab should eventually settle on the final title
|
// The tab should eventually settle on the final title
|
||||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||||
@@ -228,11 +223,10 @@ test.describe("Tab transitions (no flash)", () => {
|
|||||||
test.setTimeout(30_000);
|
test.setTimeout(30_000);
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
|
||||||
const elapsed = await measureTileTransition(
|
const elapsed = await measureTileTransition(
|
||||||
page,
|
page,
|
||||||
() => clickTerminal(page),
|
() => clickNewTerminal(page),
|
||||||
terminal.first(),
|
terminalSurfaceLocator(page),
|
||||||
20_000,
|
20_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -245,9 +239,12 @@ test.describe("Tab transitions (no flash)", () => {
|
|||||||
test("New agent tab click shows composer without flash", async ({ page }) => {
|
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
const elapsed = await measureTileTransition(
|
||||||
|
page,
|
||||||
const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
|
() => clickNewChat(page),
|
||||||
|
composerLocator(page),
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
|
||||||
// Draft creation is fully in-memory — should be fast
|
// Draft creation is fully in-memory — should be fast
|
||||||
// We use a generous budget here because CI can be slow, but the key assertion
|
// We use a generous budget here because CI can be slow, but the key assertion
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import { test, expect, type Page } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||||
|
import {
|
||||||
|
expectSettingsHeader,
|
||||||
|
openSettingsHost,
|
||||||
|
expectHostLabelDisplayed,
|
||||||
|
clickEditHostLabel,
|
||||||
|
expectHostLabelEditMode,
|
||||||
|
expectHostConnectionsCard,
|
||||||
|
expectHostInjectMcpCard,
|
||||||
|
expectHostActionCards,
|
||||||
|
expectHostNoLocalOnlyRows,
|
||||||
|
expectRetiredSidebarSectionsAbsent,
|
||||||
|
expectHostPageVisible,
|
||||||
|
expectLocalHostEntryFirst,
|
||||||
|
} from "./helpers/settings";
|
||||||
|
|
||||||
function getSeededServerId(): string {
|
function getSeededServerId(): string {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
@@ -18,17 +32,6 @@ function getSeededDaemonPort(): string {
|
|||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openHostPage(page: Page, serverId: string) {
|
|
||||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
|
||||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectHostLabelHeader(page: Page) {
|
|
||||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
|
||||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Settings host page", () => {
|
test.describe("Settings host page", () => {
|
||||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||||
page,
|
page,
|
||||||
@@ -38,28 +41,13 @@ test.describe("Settings host page", () => {
|
|||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openHostPage(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||||
await expectHostLabelHeader(page);
|
await expectHostLabelDisplayed(page);
|
||||||
|
await expectHostConnectionsCard(page, port);
|
||||||
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
|
await expectHostInjectMcpCard(page);
|
||||||
const connectionsCard = page.getByTestId("host-page-connections-card");
|
await expectHostActionCards(page);
|
||||||
await expect(connectionsCard).toBeVisible();
|
|
||||||
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
|
|
||||||
await expect(injectMcpCard).toBeVisible();
|
|
||||||
await expect(injectMcpCard.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||||
@@ -67,15 +55,11 @@ test.describe("Settings host page", () => {
|
|||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openHostPage(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
await expectHostLabelDisplayed(page);
|
||||||
|
await clickEditHostLabel(page);
|
||||||
await page.getByTestId("host-page-label-edit-button").click();
|
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||||
|
|
||||||
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL);
|
|
||||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||||
@@ -85,31 +69,17 @@ test.describe("Settings host page", () => {
|
|||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openHostPage(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||||
// Pair-device now lives behind a row that only the local host sees
|
await expectHostNoLocalOnlyRows(page);
|
||||||
// (gated by useIsLocalDaemon); the seeded host is remote, so it must
|
|
||||||
// not appear. The daemon-lifecycle card is still local-host only.
|
|
||||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
|
||||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
|
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
|
||||||
const sidebar = page.getByTestId("settings-sidebar");
|
await expectRetiredSidebarSectionsAbsent(page);
|
||||||
await expect(sidebar).toBeVisible();
|
|
||||||
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
|
||||||
|
|
||||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
|
||||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
||||||
@@ -120,9 +90,10 @@ test.describe("Settings host page", () => {
|
|||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||||
|
|
||||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
await expectHostPageVisible(page, serverId);
|
||||||
await expectHostLabelHeader(page);
|
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
await expectHostLabelDisplayed(page);
|
||||||
|
await expectHostActionCards(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||||
@@ -164,18 +135,6 @@ test.describe("Settings host page", () => {
|
|||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
await expectLocalHostEntryFirst(page, serverId);
|
||||||
const sidebar = page.getByTestId("settings-sidebar");
|
|
||||||
await expect(sidebar).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]');
|
|
||||||
await expect(hostEntries.first()).toHaveAttribute(
|
|
||||||
"data-testid",
|
|
||||||
`settings-host-entry-${serverId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
|
||||||
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
|
||||||
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,62 +1,60 @@
|
|||||||
import { test, expect, type Page } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
|
import {
|
||||||
function escapeRegex(value: string): string {
|
openSettingsSection,
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
expectSettingsHeader,
|
||||||
}
|
openAddHostFlow,
|
||||||
|
selectHostConnectionType,
|
||||||
function getServerId(): string {
|
toggleHostAdvanced,
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
openCompactSettings,
|
||||||
if (!serverId) {
|
expectCompactSettingsList,
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
expectSettingsSidebarVisible,
|
||||||
}
|
expectSettingsSidebarHidden,
|
||||||
return serverId;
|
expectSettingsSidebarSections,
|
||||||
}
|
goBackInSettings,
|
||||||
|
expectSettingsBackButton,
|
||||||
async function clickSidebarSection(page: Page, label: string) {
|
clickSettingsBackToWorkspace,
|
||||||
const sidebar = page.getByTestId("settings-sidebar");
|
verifyLegacyHostSettingsRedirect,
|
||||||
await expect(sidebar).toBeVisible();
|
openCompactSettingsHost,
|
||||||
await sidebar.getByRole("button", { name: label, exact: true }).click();
|
expectAddHostMethodOptions,
|
||||||
}
|
fillDirectHostUri,
|
||||||
|
expectDirectHostFormValues,
|
||||||
|
expectDirectHostSslEnabled,
|
||||||
|
expectDirectHostUriValue,
|
||||||
|
expectDirectHostUriHidden,
|
||||||
|
expectDiagnosticsContent,
|
||||||
|
expectAboutContent,
|
||||||
|
expectGeneralContent,
|
||||||
|
} from "./helpers/settings";
|
||||||
|
|
||||||
test.describe("Settings sidebar navigation", () => {
|
test.describe("Settings sidebar navigation", () => {
|
||||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
|
||||||
await clickSidebarSection(page, "Diagnostics");
|
await openSettingsSection(page, "diagnostics");
|
||||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
await expectSettingsHeader(page, "Diagnostics");
|
||||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
await expectDiagnosticsContent(page);
|
||||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
|
|
||||||
|
|
||||||
await clickSidebarSection(page, "About");
|
await openSettingsSection(page, "about");
|
||||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
await expectSettingsHeader(page, "About");
|
||||||
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
await expectAboutContent(page);
|
||||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
|
|
||||||
|
|
||||||
await clickSidebarSection(page, "General");
|
await openSettingsSection(page, "general");
|
||||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
await expectSettingsHeader(page, "General");
|
||||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
await expectGeneralContent(page);
|
||||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
|
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
|
||||||
const serverId = getServerId();
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
await verifyLegacyHostSettingsRedirect(page);
|
||||||
await expect(page).toHaveURL(
|
|
||||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
await openAddHostFlow(page);
|
||||||
await page.getByTestId("settings-add-host").click();
|
await expectAddHostMethodOptions(page);
|
||||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("direct connection advanced URI round-trips SSL and password into the form", async ({
|
test("direct connection advanced URI round-trips SSL and password into the form", async ({
|
||||||
@@ -64,38 +62,34 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
await openAddHostFlow(page);
|
||||||
|
await selectHostConnectionType(page, "direct");
|
||||||
|
|
||||||
await page.getByTestId("settings-add-host").click();
|
await toggleHostAdvanced(page);
|
||||||
await page.getByRole("button", { name: "Direct connection" }).click();
|
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
||||||
|
await toggleHostAdvanced(page);
|
||||||
|
|
||||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
await expectDirectHostFormValues(page, {
|
||||||
await page
|
host: "example.paseo.test",
|
||||||
.getByTestId("direct-host-uri-input")
|
port: "7443",
|
||||||
.fill("tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
password: "shared-secret",
|
||||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
});
|
||||||
|
await expectDirectHostSslEnabled(page);
|
||||||
|
await expectDirectHostUriHidden(page);
|
||||||
|
|
||||||
await expect(page.getByTestId("direct-host-input")).toHaveValue("example.paseo.test");
|
await toggleHostAdvanced(page);
|
||||||
await expect(page.getByTestId("direct-port-input")).toHaveValue("7443");
|
await expectDirectHostUriValue(
|
||||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
page,
|
||||||
await expect(page.getByTestId("direct-password-input")).toHaveValue("shared-secret");
|
|
||||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
|
||||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(
|
|
||||||
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
|
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
|
||||||
);
|
);
|
||||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
await toggleHostAdvanced(page);
|
||||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
await expectDirectHostUriHidden(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
await clickSettingsBackToWorkspace(page);
|
||||||
const backRow = page.getByTestId("settings-back-to-workspace");
|
|
||||||
await expect(backRow).toBeVisible();
|
|
||||||
|
|
||||||
await backRow.click();
|
|
||||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -103,113 +97,57 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
test.describe("Settings — compact master-detail", () => {
|
test.describe("Settings — compact master-detail", () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 } });
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
async function openCompactSettingsRoot(page: Page) {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
// Wait for bootstrap to settle on a host route so storeReady is true before
|
|
||||||
// we navigate into the protected settings stack. A direct page.goto("/settings")
|
|
||||||
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
|
|
||||||
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
|
||||||
|
|
||||||
// Drive navigation the same way a user would: open the mobile drawer and tap
|
|
||||||
// the Settings footer icon. This preserves the in-app router state instead of
|
|
||||||
// triggering a full reload through Stack.Protected.
|
|
||||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
|
||||||
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
|
||||||
await expect(sidebarSettingsButton).toBeVisible();
|
|
||||||
await sidebarSettingsButton.click();
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/settings$/);
|
|
||||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectCompactSettingsRootList(page: Page) {
|
|
||||||
await expect(page).toHaveURL(/\/settings$/);
|
|
||||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
|
||||||
|
|
||||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
|
||||||
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||||
await openCompactSettingsRoot(page);
|
await gotoAppShell(page);
|
||||||
|
await openCompactSettings(page);
|
||||||
|
|
||||||
// Sidebar rows are present.
|
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
|
||||||
await expect(
|
await expectCompactSettingsList(page);
|
||||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page
|
|
||||||
.getByTestId("settings-sidebar")
|
|
||||||
.getByRole("button", { name: "Diagnostics", exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// Section detail content is NOT rendered at the root.
|
await expectSettingsBackButton(page);
|
||||||
await expectCompactSettingsRootList(page);
|
await goBackInSettings(page);
|
||||||
|
|
||||||
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
|
|
||||||
await expect(rootBackButton).toBeVisible();
|
|
||||||
await rootBackButton.click();
|
|
||||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||||
await openCompactSettingsRoot(page);
|
await gotoAppShell(page);
|
||||||
|
await openCompactSettings(page);
|
||||||
|
|
||||||
await page
|
await openSettingsSection(page, "diagnostics");
|
||||||
.getByTestId("settings-sidebar")
|
|
||||||
.getByRole("button", { name: "Diagnostics", exact: true })
|
|
||||||
.click();
|
|
||||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
await expectDiagnosticsContent(page);
|
||||||
// Sidebar is no longer visible — we are on a detail screen.
|
await expectSettingsSidebarHidden(page);
|
||||||
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
|
await expectSettingsBackButton(page);
|
||||||
// only visible instances.)
|
|
||||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
|
||||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
||||||
await openCompactSettingsRoot(page);
|
await gotoAppShell(page);
|
||||||
|
await openCompactSettings(page);
|
||||||
|
|
||||||
await page
|
await openSettingsSection(page, "about");
|
||||||
.getByTestId("settings-sidebar")
|
|
||||||
.getByRole("button", { name: "About", exact: true })
|
|
||||||
.click();
|
|
||||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
await goBackInSettings(page);
|
||||||
await expectCompactSettingsRootList(page);
|
await expectCompactSettingsList(page);
|
||||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
await expectSettingsBackButton(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
||||||
const serverId = getServerId();
|
await gotoAppShell(page);
|
||||||
await openCompactSettingsRoot(page);
|
await openCompactSettings(page);
|
||||||
|
|
||||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
await openCompactSettingsHost(page);
|
||||||
await expect(page).toHaveURL(
|
await expectSettingsBackButton(page);
|
||||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
await expectSettingsSidebarHidden(page);
|
||||||
);
|
|
||||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
|
||||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||||
const serverId = getServerId();
|
await gotoAppShell(page);
|
||||||
await openCompactSettingsRoot(page);
|
await openCompactSettings(page);
|
||||||
|
|
||||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
await openCompactSettingsHost(page);
|
||||||
await expect(page).toHaveURL(
|
await goBackInSettings(page);
|
||||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
|
||||||
await expect(page).toHaveURL(/\/settings$/);
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
await expectSettingsSidebarVisible(page);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import {
|
import {
|
||||||
archiveAgentFromDaemon,
|
archiveAgentFromDaemon,
|
||||||
connectArchiveTabDaemonClient,
|
connectArchiveTabDaemonClient,
|
||||||
createIdleAgent,
|
createIdleAgent,
|
||||||
openWorkspaceWithAgents,
|
openWorkspaceWithAgents,
|
||||||
} from "./helpers/archive-tab";
|
} from "./helpers/archive-tab";
|
||||||
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
|
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
|
||||||
function getServerId(): string {
|
function getServerId(): string {
|
||||||
@@ -17,12 +17,15 @@ function getServerId(): string {
|
|||||||
return serverId;
|
return serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pressSettingsToggleShortcut(page: Page) {
|
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
|
||||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
await page.keyboard.press(`${modifier}+Comma`);
|
await page.keyboard.press(`${modifier}+Comma`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
|
async function expectSendBehavior(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
expected: "interrupt" | "queue",
|
||||||
|
) {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||||
@@ -35,12 +38,11 @@ async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openAgentRouteAndExpectFocused(input: {
|
async function openAgentRouteAndExpectFocused(input: {
|
||||||
page: Page;
|
page: import("@playwright/test").Page;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
}) {
|
}) {
|
||||||
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
|
|
||||||
await input.page.goto(
|
await input.page.goto(
|
||||||
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
|
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
|
||||||
);
|
);
|
||||||
@@ -49,10 +51,7 @@ async function openAgentRouteAndExpectFocused(input: {
|
|||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
);
|
);
|
||||||
await waitForTabBar(input.page);
|
await waitForTabBar(input.page);
|
||||||
await expect(
|
await expectAgentTabActive(input.page, input.agentId);
|
||||||
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
|
||||||
).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Settings toggle tab regression", () => {
|
test.describe("Settings toggle tab regression", () => {
|
||||||
@@ -79,13 +78,7 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
|
|
||||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
|
await expectAgentTabActive(page, secondAgent.id);
|
||||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
|
||||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
|
|
||||||
await pressSettingsToggleShortcut(page);
|
await pressSettingsToggleShortcut(page);
|
||||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
@@ -98,19 +91,11 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
await pressSettingsToggleShortcut(page);
|
await pressSettingsToggleShortcut(page);
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
await expectAgentTabActive(page, secondAgent.id);
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
await expectAgentTabActive(page, secondAgent.id);
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
} finally {
|
} finally {
|
||||||
for (const agentId of agentIds) {
|
for (const agentId of agentIds) {
|
||||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||||
@@ -152,14 +137,10 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
agentId: secondAgent.id,
|
agentId: secondAgent.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
await expect(
|
await expectAgentTabActive(page, secondAgent.id);
|
||||||
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
|
||||||
).toHaveAttribute("aria-selected", "true");
|
|
||||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
for (const agentId of agentIds) {
|
for (const agentId of agentIds) {
|
||||||
|
|||||||
@@ -1,129 +1,45 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { test } from "./fixtures";
|
||||||
import { realpathSync } from "node:fs";
|
import { clickNewTerminal } from "./helpers/launcher";
|
||||||
import path from "node:path";
|
|
||||||
import { expect, test } from "./fixtures";
|
|
||||||
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
|
||||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
import {
|
||||||
connectWorkspaceSetupClient,
|
expectTerminalSurfaceVisible,
|
||||||
openHomeWithProject,
|
focusTerminalSurface,
|
||||||
seedProjectForWorkspaceSetup,
|
typeInTerminal,
|
||||||
} from "./helpers/workspace-setup";
|
setupDeterministicPrompt,
|
||||||
|
waitForTerminalContent,
|
||||||
function getServerId(): string {
|
} from "./helpers/terminal-perf";
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Navigate to a workspace via sidebar row testID and wait for tab bar. */
|
|
||||||
async function navigateToWorkspaceViaSidebar(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
|
||||||
const row = page.getByTestId(testId);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
await waitForTabBar(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Workspace cwd correctness", () => {
|
test.describe("Workspace cwd correctness", () => {
|
||||||
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
|
test("main checkout workspace opens terminals in the project root", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
const client = await connectWorkspaceSetupClient();
|
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
|
||||||
const repo = await createTempGitRepo("workspace-cwd-main-");
|
await workspace.navigateTo();
|
||||||
|
await clickNewTerminal(page);
|
||||||
|
|
||||||
try {
|
await expectTerminalSurfaceVisible(page);
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
await focusTerminalSurface(page);
|
||||||
|
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||||
const workspaceResult = await client.openProject(repo.path);
|
await typeInTerminal(page, "pwd\n");
|
||||||
if (!workspaceResult.workspace) {
|
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
|
||||||
}
|
|
||||||
const workspaceId = workspaceResult.workspace.id;
|
|
||||||
|
|
||||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
await clickTerminal(page);
|
|
||||||
|
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
|
||||||
await terminal.first().click();
|
|
||||||
|
|
||||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
|
||||||
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
|
||||||
|
|
||||||
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
|
test("worktree workspace opens terminals in the worktree directory", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
const client = await connectWorkspaceSetupClient();
|
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
|
||||||
const repo = await createTempGitRepo("workspace-cwd-worktree-");
|
await workspace.navigateTo();
|
||||||
const resolvedTmp = realpathSync("/tmp");
|
await clickNewTerminal(page);
|
||||||
const worktreePath = path.join(
|
|
||||||
resolvedTmp,
|
|
||||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
);
|
|
||||||
const branchName = `workspace-cwd-${Date.now()}`;
|
|
||||||
let worktreeCreated = false;
|
|
||||||
|
|
||||||
try {
|
await expectTerminalSurfaceVisible(page);
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
await focusTerminalSurface(page);
|
||||||
|
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||||
execSync(
|
await typeInTerminal(page, "pwd\n");
|
||||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||||
{
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
worktreeCreated = true;
|
|
||||||
|
|
||||||
const workspaceResult = await client.openProject(worktreePath);
|
|
||||||
if (!workspaceResult.workspace) {
|
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
|
||||||
}
|
|
||||||
const workspaceId = workspaceResult.workspace.id;
|
|
||||||
|
|
||||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
|
||||||
// with direct URL navigation to the 2nd+ workspace.
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
|
|
||||||
await clickTerminal(page);
|
|
||||||
|
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
|
||||||
await terminal.first().click();
|
|
||||||
|
|
||||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
|
||||||
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
|
||||||
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
|
|
||||||
} finally {
|
|
||||||
if (worktreeCreated) {
|
|
||||||
try {
|
|
||||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Best-effort cleanup so test failures preserve the original error.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +1,9 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { test } from "./fixtures";
|
||||||
import { realpathSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { expect, test } from "./fixtures";
|
|
||||||
import { waitForTabBar } from "./helpers/launcher";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
import {
|
||||||
createAgentChatFromLauncher,
|
createAgentChatFromLauncher,
|
||||||
createStandaloneTerminalFromLauncher,
|
createStandaloneTerminalFromLauncher,
|
||||||
expectTerminalCwd,
|
expectTerminalCwd,
|
||||||
} from "./helpers/workspace-lifecycle";
|
} from "./helpers/workspace-lifecycle";
|
||||||
import {
|
|
||||||
connectWorkspaceSetupClient,
|
|
||||||
openHomeWithProject,
|
|
||||||
seedProjectForWorkspaceSetup,
|
|
||||||
} from "./helpers/workspace-setup";
|
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Navigate to a workspace via sidebar row testID and wait for the tab bar. */
|
|
||||||
async function navigateToWorkspaceViaSidebar(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
|
||||||
const row = page.getByTestId(testId);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
await waitForTabBar(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Workspace lifecycle", () => {
|
test.describe("Workspace lifecycle", () => {
|
||||||
// The first test after a spec-file switch can intermittently fail because
|
// The first test after a spec-file switch can intermittently fail because
|
||||||
@@ -42,154 +12,36 @@ test.describe("Workspace lifecycle", () => {
|
|||||||
test.describe.configure({ retries: 1 });
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
test.describe("Main checkout", () => {
|
test.describe("Main checkout", () => {
|
||||||
test("creates an agent chat via New Chat", async ({ page }) => {
|
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
|
||||||
const client = await connectWorkspaceSetupClient();
|
await workspace.navigateTo();
|
||||||
const repo = await createTempGitRepo("lifecycle-main-chat-");
|
await createAgentChatFromLauncher(page);
|
||||||
|
|
||||||
try {
|
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
|
||||||
const workspaceResult = await client.openProject(repo.path);
|
|
||||||
if (!workspaceResult.workspace) {
|
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
|
||||||
}
|
|
||||||
const workspaceId = workspaceResult.workspace.id;
|
|
||||||
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
await createAgentChatFromLauncher(page);
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
|
||||||
const client = await connectWorkspaceSetupClient();
|
await workspace.navigateTo();
|
||||||
const repo = await createTempGitRepo("lifecycle-main-shell-");
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
|
await expectTerminalCwd(page, workspace.repoPath);
|
||||||
try {
|
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
|
||||||
const workspaceResult = await client.openProject(repo.path);
|
|
||||||
if (!workspaceResult.workspace) {
|
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
|
||||||
}
|
|
||||||
const workspaceId = workspaceResult.workspace.id;
|
|
||||||
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
await createStandaloneTerminalFromLauncher(page);
|
|
||||||
await expectTerminalCwd(page, repo.path);
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Worktree workspace", () => {
|
test.describe("Worktree workspace", () => {
|
||||||
test("creates an agent chat via New Chat", async ({ page }) => {
|
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
|
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
|
||||||
const client = await connectWorkspaceSetupClient();
|
await workspace.navigateTo();
|
||||||
const repo = await createTempGitRepo("lifecycle-wt-chat-");
|
await createAgentChatFromLauncher(page);
|
||||||
const resolvedTmp = realpathSync("/tmp");
|
|
||||||
const worktreePath = path.join(
|
|
||||||
resolvedTmp,
|
|
||||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
);
|
|
||||||
const branchName = `lifecycle-wt-chat-${Date.now()}`;
|
|
||||||
let worktreeCreated = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
|
||||||
|
|
||||||
execSync(
|
|
||||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
|
||||||
{
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
worktreeCreated = true;
|
|
||||||
|
|
||||||
const workspaceResult = await client.openProject(worktreePath);
|
|
||||||
if (!workspaceResult.workspace) {
|
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
|
||||||
}
|
|
||||||
const workspaceId = workspaceResult.workspace.id;
|
|
||||||
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
await createAgentChatFromLauncher(page);
|
|
||||||
} finally {
|
|
||||||
if (worktreeCreated) {
|
|
||||||
try {
|
|
||||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Best-effort cleanup so test failures preserve the original error.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
|
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
|
||||||
const client = await connectWorkspaceSetupClient();
|
await workspace.navigateTo();
|
||||||
const repo = await createTempGitRepo("lifecycle-wt-shell-");
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
const resolvedTmp = realpathSync("/tmp");
|
await expectTerminalCwd(page, workspace.repoPath);
|
||||||
const worktreePath = path.join(
|
|
||||||
resolvedTmp,
|
|
||||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
);
|
|
||||||
const branchName = `lifecycle-wt-shell-${Date.now()}`;
|
|
||||||
let worktreeCreated = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
|
||||||
|
|
||||||
execSync(
|
|
||||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
|
||||||
{
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
worktreeCreated = true;
|
|
||||||
|
|
||||||
const workspaceResult = await client.openProject(worktreePath);
|
|
||||||
if (!workspaceResult.workspace) {
|
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
|
||||||
}
|
|
||||||
const workspaceId = workspaceResult.workspace.id;
|
|
||||||
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
|
||||||
await createStandaloneTerminalFromLauncher(page);
|
|
||||||
await expectTerminalCwd(page, worktreePath);
|
|
||||||
} finally {
|
|
||||||
if (worktreeCreated) {
|
|
||||||
try {
|
|
||||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Best-effort cleanup so test failures preserve the original error.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import type { WebSocketRoute } from "@playwright/test";
|
import type { WebSocketRoute } from "@playwright/test";
|
||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
archiveAgentFromDaemon,
|
archiveAgentFromDaemon,
|
||||||
connectArchiveTabDaemonClient,
|
connectArchiveTabDaemonClient,
|
||||||
@@ -15,17 +15,26 @@ import {
|
|||||||
connectNewWorkspaceDaemonClient,
|
connectNewWorkspaceDaemonClient,
|
||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
|
import { expectComposerVisible } from "./helpers/composer";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import {
|
import {
|
||||||
getVisibleWorkspaceAgentTabIds,
|
getVisibleWorkspaceAgentTabIds,
|
||||||
expectOnlyWorkspaceAgentTabsVisible,
|
expectOnlyWorkspaceAgentTabsVisible,
|
||||||
waitForWorkspaceTabsVisible,
|
waitForWorkspaceTabsVisible,
|
||||||
|
expectWorkspaceTabsAbsent,
|
||||||
} from "./helpers/workspace-tabs";
|
} from "./helpers/workspace-tabs";
|
||||||
import {
|
import {
|
||||||
expectSidebarWorkspaceSelected,
|
expectSidebarWorkspaceSelected,
|
||||||
expectWorkspaceHeader,
|
expectWorkspaceHeader,
|
||||||
|
expectWorkspaceHeaderAbsent,
|
||||||
|
expectMenuButtonVisible,
|
||||||
|
expectHostConnectingOrOffline,
|
||||||
|
expectReconnectingToastVisible,
|
||||||
|
expectReconnectingToastGone,
|
||||||
switchWorkspaceViaSidebar,
|
switchWorkspaceViaSidebar,
|
||||||
waitForSidebarHydration,
|
waitForSidebarHydration,
|
||||||
|
workspaceDeckEntryLocator,
|
||||||
|
expectWorkspaceDeckEntryCount,
|
||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
|
|
||||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||||
@@ -62,6 +71,10 @@ async function expectNoLoadingWorkspacePane(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectNoLoadingPane(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
|
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
|
||||||
let acceptingConnections = true;
|
let acceptingConnections = true;
|
||||||
const activeSockets = new Set<WebSocketRoute>();
|
const activeSockets = new Set<WebSocketRoute>();
|
||||||
@@ -159,35 +172,31 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
title: workspace.workspaceName,
|
title: workspace.workspaceName,
|
||||||
subtitle: workspace.projectDisplayName,
|
subtitle: workspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await expectWorkspaceTabVisible(page, agent.id);
|
await expectWorkspaceTabVisible(page, agent.id);
|
||||||
|
|
||||||
await daemonGate.drop();
|
await daemonGate.drop();
|
||||||
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
await expectReconnectingToastVisible(page);
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: workspace.workspaceName,
|
title: workspace.workspaceName,
|
||||||
subtitle: workspace.projectDisplayName,
|
subtitle: workspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
await expectComposerVisible(page);
|
||||||
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
|
await expectNoLoadingPane(page);
|
||||||
|
|
||||||
const monitorReconnect = expectNoLoadingWorkspacePane(page, {
|
const monitorReconnect = expectNoLoadingWorkspacePane(page, {
|
||||||
label: "host reconnect",
|
label: "host reconnect",
|
||||||
});
|
});
|
||||||
daemonGate.restore();
|
daemonGate.restore();
|
||||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
|
await expectReconnectingToastGone(page);
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await monitorReconnect;
|
await monitorReconnect;
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: workspace.workspaceName,
|
title: workspace.workspaceName,
|
||||||
subtitle: workspace.projectDisplayName,
|
subtitle: workspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
await expectComposerVisible(page);
|
||||||
} finally {
|
} finally {
|
||||||
daemonGate.restore();
|
daemonGate.restore();
|
||||||
for (const agentId of agentIds) {
|
for (const agentId of agentIds) {
|
||||||
@@ -222,16 +231,11 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expectHostConnectingOrOffline(page);
|
||||||
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
|
await expectMenuButtonVisible(page);
|
||||||
).toBeVisible({ timeout: 30_000 });
|
await expectWorkspaceHeaderAbsent(page);
|
||||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
await expectWorkspaceTabsAbsent(page);
|
||||||
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
|
await openSettings(page);
|
||||||
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
|
|
||||||
|
|
||||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
|
||||||
await expect(settingsButton).toBeVisible({ timeout: 30_000 });
|
|
||||||
await settingsButton.click();
|
|
||||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,6 +274,13 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||||
|
|
||||||
|
const firstDeckEntry = workspaceDeckEntryLocator(page, serverId, firstWorkspace.workspaceId);
|
||||||
|
const secondDeckEntry = workspaceDeckEntryLocator(
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
secondWorkspace.workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
page,
|
page,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -300,13 +311,6 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||||
`workspace-tab-agent_${firstAgent.id}`,
|
`workspace-tab-agent_${firstAgent.id}`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const firstDeckEntry = page.getByTestId(
|
|
||||||
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
|
|
||||||
);
|
|
||||||
const secondDeckEntry = page.getByTestId(
|
|
||||||
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
|
|
||||||
);
|
|
||||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
@@ -342,7 +346,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expect(firstDeckEntry).toBeAttached();
|
await expect(firstDeckEntry).toBeAttached();
|
||||||
await expect(firstDeckEntry).toBeHidden();
|
await expect(firstDeckEntry).toBeHidden();
|
||||||
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
await expectWorkspaceDeckEntryCount(page, 2);
|
||||||
|
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ agentId, serverId: targetServerId }) => {
|
({ agentId, serverId: targetServerId }) => {
|
||||||
@@ -371,7 +375,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||||
await expect(firstDeckEntry).toBeAttached();
|
await expect(firstDeckEntry).toBeAttached();
|
||||||
await expect(firstDeckEntry).toBeHidden();
|
await expect(firstDeckEntry).toBeHidden();
|
||||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
await expectWorkspaceDeckEntryCount(page, 2);
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
page,
|
page,
|
||||||
@@ -385,7 +389,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(secondDeckEntry).toBeAttached();
|
await expect(secondDeckEntry).toBeAttached();
|
||||||
await expect(secondDeckEntry).toBeHidden();
|
await expect(secondDeckEntry).toBeHidden();
|
||||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
await expectWorkspaceDeckEntryCount(page, 2);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
|
|||||||
@@ -1,32 +1,16 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { expect, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
import { clickNewTerminal } from "./helpers/launcher";
|
||||||
|
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
|
||||||
import {
|
import {
|
||||||
connectWorkspaceSetupClient,
|
connectWorkspaceSetupClient,
|
||||||
createWorkspaceThroughDaemon,
|
createWorkspaceThroughDaemon,
|
||||||
findWorktreeWorkspaceForProject,
|
findWorktreeWorkspaceForProject,
|
||||||
|
navigateToWorkspaceViaSidebar,
|
||||||
openHomeWithProject,
|
openHomeWithProject,
|
||||||
} from "./helpers/workspace-setup";
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function navigateToWorkspaceViaSidebar(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
await waitForTabBar(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Workspace setup runtime authority", () => {
|
test.describe("Workspace setup runtime authority", () => {
|
||||||
test.describe.configure({ retries: 1 });
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
@@ -48,7 +32,6 @@ test.describe("Workspace setup runtime authority", () => {
|
|||||||
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
|
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
|
||||||
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
|
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
|
||||||
|
|
||||||
// Navigate to the workspace via sidebar
|
|
||||||
await openHomeWithProject(page, repo.path);
|
await openHomeWithProject(page, repo.path);
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
@@ -86,10 +69,8 @@ test.describe("Workspace setup runtime authority", () => {
|
|||||||
await openHomeWithProject(page, repo.path);
|
await openHomeWithProject(page, repo.path);
|
||||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
|
|
||||||
await clickTerminal(page);
|
await clickNewTerminal(page);
|
||||||
|
await expectTerminalSurfaceVisible(page);
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
|
||||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
|
||||||
|
|
||||||
// Verify terminal is listed under the worktree directory, not the original repo
|
// Verify terminal is listed under the worktree directory, not the original repo
|
||||||
await expect
|
await expect
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
import {
|
||||||
|
waitForWorkspaceTabsVisible,
|
||||||
|
expectNoTerminalTabs,
|
||||||
|
clickFirstTerminalTab,
|
||||||
|
expectFirstTerminalTabContains,
|
||||||
|
} from "./helpers/workspace-tabs";
|
||||||
|
import { clickNewChat } from "./helpers/launcher";
|
||||||
|
import { expectComposerVisible } from "./helpers/composer";
|
||||||
|
import { openFileExplorer, expectExplorerEntryVisible } from "./helpers/file-explorer";
|
||||||
|
import { expectTerminalSurfaceVisible, waitForTerminalAttached } from "./helpers/terminal-perf";
|
||||||
import {
|
import {
|
||||||
connectWorkspaceSetupClient,
|
connectWorkspaceSetupClient,
|
||||||
createWorkspaceThroughDaemon,
|
createWorkspaceThroughDaemon,
|
||||||
expectSetupPanel,
|
expectSetupPanel,
|
||||||
openHomeWithProject,
|
openHomeWithProject,
|
||||||
|
navigateToWorkspaceViaSidebar,
|
||||||
|
openWorkspaceScriptsMenu,
|
||||||
|
startWorkspaceScriptFromMenu,
|
||||||
|
closeWorkspaceScriptsMenu,
|
||||||
seedProjectForWorkspaceSetup,
|
seedProjectForWorkspaceSetup,
|
||||||
waitForWorkspaceSetupProgress,
|
waitForWorkspaceSetupProgress,
|
||||||
} from "./helpers/workspace-setup";
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkspaceScriptStarter {
|
interface WorkspaceScriptStarter {
|
||||||
startWorkspaceScript(
|
startWorkspaceScript(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -30,18 +35,6 @@ interface WorkspaceScriptStarter {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
|
||||||
async function navigateToWorkspaceViaSidebar(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
|
||||||
const row = page.getByTestId(testId);
|
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
||||||
await row.click();
|
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Workspace setup streaming", () => {
|
test.describe("Workspace setup streaming", () => {
|
||||||
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const client = await connectWorkspaceSetupClient();
|
||||||
@@ -100,31 +93,15 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
});
|
});
|
||||||
await completed;
|
await completed;
|
||||||
|
|
||||||
// Navigate to workspace and verify it's usable
|
|
||||||
await openHomeWithProject(page, repo.path);
|
await openHomeWithProject(page, repo.path);
|
||||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
|
await clickNewChat(page);
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
await expectComposerVisible(page, { timeout: 30_000 });
|
||||||
timeout: 30_000,
|
await openFileExplorer(page);
|
||||||
});
|
await expectExplorerEntryVisible(page, "README.md");
|
||||||
|
await expectExplorerEntryVisible(page, "src");
|
||||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
|
||||||
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
|
|
||||||
await explorerToggle.click();
|
|
||||||
}
|
|
||||||
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await page.getByTestId("explorer-tab-files").click();
|
|
||||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
@@ -273,33 +250,14 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expectNoTerminalTabs(page);
|
||||||
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
await openWorkspaceScriptsMenu(page);
|
||||||
await page.getByTestId("workspace-scripts-button").click();
|
await startWorkspaceScriptFromMenu(page, "web");
|
||||||
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
await closeWorkspaceScriptsMenu(page);
|
||||||
await page.getByTestId("workspace-scripts-start-web").click();
|
await clickFirstTerminalTab(page);
|
||||||
await page.getByTestId("workspace-scripts-menu-backdrop").click();
|
await expectTerminalSurfaceVisible(page, { timeout: 10_000 });
|
||||||
|
await waitForTerminalAttached(page);
|
||||||
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
await expectFirstTerminalTabContains(page, "web");
|
||||||
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
|
||||||
|
|
||||||
await terminalTab.click();
|
|
||||||
|
|
||||||
// Verify the terminal surface rendered
|
|
||||||
const terminalSurface = page.getByTestId("terminal-surface").first();
|
|
||||||
await expect(terminalSurface).toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
// Wait for terminal to fully attach (loading overlay gone)
|
|
||||||
await page
|
|
||||||
.locator('[data-testid="terminal-attach-loading"]')
|
|
||||||
.waitFor({ state: "hidden", timeout: 10_000 })
|
|
||||||
.catch(() => {
|
|
||||||
// overlay may never appear if attachment is instant
|
|
||||||
});
|
|
||||||
|
|
||||||
await terminalSurface.click();
|
|
||||||
|
|
||||||
await expect(terminalTab).toContainText("web");
|
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ import type {
|
|||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
import { AttachmentPill } from "@/components/attachment-pill";
|
import { AttachmentPill } from "@/components/attachment-pill";
|
||||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||||
|
import {
|
||||||
|
isWorkspaceAttachment,
|
||||||
|
userAttachmentsOnly,
|
||||||
|
workspaceAttachmentToSubmitAttachment,
|
||||||
|
} from "@/attachments/workspace-attachment-utils";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { AgentAttachment } from "@server/shared/messages";
|
|
||||||
import { useClearReviewDraft } from "@/review/store";
|
import { useClearReviewDraft } from "@/review/store";
|
||||||
|
|
||||||
interface WorkspaceAttachmentBindingInput {
|
interface WorkspaceAttachmentBindingInput {
|
||||||
@@ -69,31 +73,6 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWorkspaceAttachment(
|
|
||||||
attachment: ComposerAttachment | undefined,
|
|
||||||
): attachment is WorkspaceComposerAttachment {
|
|
||||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
|
||||||
}
|
|
||||||
|
|
||||||
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
|
|
||||||
return attachments.filter(
|
|
||||||
(attachment): attachment is UserComposerAttachment =>
|
|
||||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
|
|
||||||
if (attachment.kind === "browser_element") {
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
mimeType: "text/plain",
|
|
||||||
title: `Browser element · ${attachment.attachment.tag}`,
|
|
||||||
text: attachment.attachment.formatted,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return attachment.kind === "review" ? attachment.attachment : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
||||||
return (
|
return (
|
||||||
<WorkspaceAttachmentPill
|
<WorkspaceAttachmentPill
|
||||||
@@ -303,7 +282,7 @@ function WorkspaceAttachmentPill({
|
|||||||
export const composerWorkspaceAttachment = {
|
export const composerWorkspaceAttachment = {
|
||||||
is: isWorkspaceAttachment,
|
is: isWorkspaceAttachment,
|
||||||
renderPill,
|
renderPill,
|
||||||
toSubmitAttachment,
|
toSubmitAttachment: workspaceAttachmentToSubmitAttachment,
|
||||||
userAttachmentsOnly,
|
userAttachmentsOnly,
|
||||||
useBinding: useWorkspaceAttachmentBinding,
|
useBinding: useWorkspaceAttachmentBinding,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type {
|
||||||
|
ComposerAttachment,
|
||||||
|
UserComposerAttachment,
|
||||||
|
WorkspaceComposerAttachment,
|
||||||
|
} from "@/attachments/types";
|
||||||
|
import type { AgentAttachment } from "@server/shared/messages";
|
||||||
|
|
||||||
|
export function isWorkspaceAttachment(
|
||||||
|
attachment: ComposerAttachment | undefined,
|
||||||
|
): attachment is WorkspaceComposerAttachment {
|
||||||
|
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userAttachmentsOnly(
|
||||||
|
attachments: readonly ComposerAttachment[],
|
||||||
|
): UserComposerAttachment[] {
|
||||||
|
return attachments.filter(
|
||||||
|
(attachment): attachment is UserComposerAttachment =>
|
||||||
|
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceAttachmentToSubmitAttachment(
|
||||||
|
attachment: ComposerAttachment,
|
||||||
|
): AgentAttachment | null {
|
||||||
|
if (attachment.kind === "browser_element") {
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
title: `Browser element · ${attachment.attachment.tag}`,
|
||||||
|
text: attachment.attachment.formatted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return attachment.kind === "review" ? attachment.attachment : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import type { NeighborResolver } from "./agent-stream-view-data";
|
||||||
|
import {
|
||||||
|
getAssistantBlockSpacing,
|
||||||
|
isSameAssistantBlockGroup,
|
||||||
|
resolveInlineWorkingIndicatorItemId,
|
||||||
|
} from "./agent-stream-view-data";
|
||||||
|
|
||||||
|
// Minimal forward-order resolver: "below" = next item in array.
|
||||||
|
// Matches web strategy rendering order (chronological, top-to-bottom).
|
||||||
|
const forwardStrategy: NeighborResolver = {
|
||||||
|
getNeighborItem(items, index, relation) {
|
||||||
|
const neighborIndex = relation === "below" ? index + 1 : index - 1;
|
||||||
|
return items[neighborIndex];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function assistantBlock(params: {
|
||||||
|
id: string;
|
||||||
|
blockGroupId: string;
|
||||||
|
blockIndex: number;
|
||||||
|
text?: string;
|
||||||
|
}): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||||
|
return {
|
||||||
|
kind: "assistant_message",
|
||||||
|
id: params.id,
|
||||||
|
blockGroupId: params.blockGroupId,
|
||||||
|
blockIndex: params.blockIndex,
|
||||||
|
text: params.text ?? "",
|
||||||
|
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallBlock(id: string): Extract<StreamItem, { kind: "tool_call" }> {
|
||||||
|
return {
|
||||||
|
kind: "tool_call",
|
||||||
|
id,
|
||||||
|
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
payload: {
|
||||||
|
source: "orchestrator",
|
||||||
|
data: {
|
||||||
|
toolCallId: id,
|
||||||
|
toolName: "bash",
|
||||||
|
arguments: "cmd",
|
||||||
|
result: null,
|
||||||
|
status: "executing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isSameAssistantBlockGroup", () => {
|
||||||
|
it("returns true for two assistant blocks with the same blockGroupId", () => {
|
||||||
|
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const b = assistantBlock({ id: "b", blockGroupId: "group-1", blockIndex: 1 });
|
||||||
|
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for blocks from different groups", () => {
|
||||||
|
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const b = assistantBlock({ id: "b", blockGroupId: "group-2", blockIndex: 0 });
|
||||||
|
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when one item is not an assistant_message", () => {
|
||||||
|
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const tc = toolCallBlock("tc-1");
|
||||||
|
expect(isSameAssistantBlockGroup({ item: a, other: tc })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for null neighbors", () => {
|
||||||
|
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
expect(isSameAssistantBlockGroup({ item: a, other: null })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAssistantBlockSpacing", () => {
|
||||||
|
it("returns default for non-assistant items", () => {
|
||||||
|
const tc = toolCallBlock("tc-1");
|
||||||
|
expect(getAssistantBlockSpacing({ item: tc, aboveItem: null, belowItem: null })).toBe(
|
||||||
|
"default",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default when no same-group neighbors exist", () => {
|
||||||
|
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns compactTop when the item above is in the same block group", () => {
|
||||||
|
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||||
|
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: null })).toBe(
|
||||||
|
"compactTop",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns compactBottom when the item below is in the same block group", () => {
|
||||||
|
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 1 });
|
||||||
|
expect(getAssistantBlockSpacing({ item, aboveItem: null, belowItem: below })).toBe(
|
||||||
|
"compactBottom",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns compactBoth when both neighbors are in the same block group", () => {
|
||||||
|
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||||
|
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 2 });
|
||||||
|
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: below })).toBe(
|
||||||
|
"compactBoth",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spans the history/live-head boundary: tail gets compactBottom, head gets compactTop", () => {
|
||||||
|
const tailBlock = assistantBlock({
|
||||||
|
id: "group-1:block:0",
|
||||||
|
blockGroupId: "group-1",
|
||||||
|
blockIndex: 0,
|
||||||
|
text: "First paragraph",
|
||||||
|
});
|
||||||
|
const headBlock = assistantBlock({
|
||||||
|
id: "group-1:head",
|
||||||
|
blockGroupId: "group-1",
|
||||||
|
blockIndex: 1,
|
||||||
|
text: "Second paragraph",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAssistantBlockSpacing({ item: tailBlock, aboveItem: null, belowItem: headBlock }),
|
||||||
|
).toBe("compactBottom");
|
||||||
|
expect(
|
||||||
|
getAssistantBlockSpacing({ item: headBlock, aboveItem: tailBlock, belowItem: null }),
|
||||||
|
).toBe("compactTop");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveInlineWorkingIndicatorItemId", () => {
|
||||||
|
it("returns null when the agent is not running", () => {
|
||||||
|
const head = assistantBlock({ id: "head", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
expect(resolveInlineWorkingIndicatorItemId("idle", [head], forwardStrategy)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the last assistant block id when running with a single head block", () => {
|
||||||
|
const head = assistantBlock({ id: "group-1:head", blockGroupId: "group-1", blockIndex: 0 });
|
||||||
|
expect(resolveInlineWorkingIndicatorItemId("running", [head], forwardStrategy)).toBe(
|
||||||
|
"group-1:head",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when live head contains only a tool call (uses auxiliary indicator instead)", () => {
|
||||||
|
const tc = toolCallBlock("tool-1");
|
||||||
|
expect(resolveInlineWorkingIndicatorItemId("running", [tc], forwardStrategy)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the footer assistant block when history and streaming head coexist", () => {
|
||||||
|
const historyBlock = assistantBlock({
|
||||||
|
id: "group-1:block:0",
|
||||||
|
blockGroupId: "group-1",
|
||||||
|
blockIndex: 0,
|
||||||
|
});
|
||||||
|
const streamingBlock = assistantBlock({
|
||||||
|
id: "group-2:head",
|
||||||
|
blockGroupId: "group-2",
|
||||||
|
blockIndex: 0,
|
||||||
|
});
|
||||||
|
// historyBlock is in streamItems (tail), not liveHead — liveHead holds only the streaming block
|
||||||
|
expect(resolveInlineWorkingIndicatorItemId("running", [streamingBlock], forwardStrategy)).toBe(
|
||||||
|
"group-2:head",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveInlineWorkingIndicatorItemId(
|
||||||
|
"running",
|
||||||
|
[historyBlock, streamingBlock],
|
||||||
|
forwardStrategy,
|
||||||
|
),
|
||||||
|
).toBe("group-2:head");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
|
||||||
|
export function isSameAssistantBlockGroup(params: {
|
||||||
|
item: StreamItem | null | undefined;
|
||||||
|
other: StreamItem | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
params.item?.kind === "assistant_message" &&
|
||||||
|
params.other?.kind === "assistant_message" &&
|
||||||
|
params.item.blockGroupId !== undefined &&
|
||||||
|
params.item.blockGroupId === params.other.blockGroupId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAssistantBlockSpacing(params: {
|
||||||
|
item: StreamItem;
|
||||||
|
aboveItem: StreamItem | null | undefined;
|
||||||
|
belowItem: StreamItem | null | undefined;
|
||||||
|
}): "default" | "compactTop" | "compactBottom" | "compactBoth" {
|
||||||
|
if (params.item.kind !== "assistant_message") {
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem });
|
||||||
|
const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem });
|
||||||
|
if (compactTop && compactBottom) return "compactBoth";
|
||||||
|
if (compactTop) return "compactTop";
|
||||||
|
if (compactBottom) return "compactBottom";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NeighborResolver {
|
||||||
|
getNeighborItem(
|
||||||
|
items: StreamItem[],
|
||||||
|
index: number,
|
||||||
|
relation: "above" | "below",
|
||||||
|
): StreamItem | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// null → auxiliary working indicator; non-null → inline footer on that block.
|
||||||
|
export function resolveInlineWorkingIndicatorItemId(
|
||||||
|
status: string,
|
||||||
|
liveHeadItems: StreamItem[],
|
||||||
|
strategy: NeighborResolver,
|
||||||
|
): string | null {
|
||||||
|
if (status !== "running") return null;
|
||||||
|
const footerItem = liveHeadItems.find((item, index, items) => {
|
||||||
|
if (item.kind !== "assistant_message") return false;
|
||||||
|
return strategy.getNeighborItem(items, index, "below") === undefined;
|
||||||
|
});
|
||||||
|
return footerItem?.id ?? null;
|
||||||
|
}
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { StreamItem } from "@/types/stream";
|
|
||||||
import { AgentStreamView } from "./agent-stream-view";
|
|
||||||
|
|
||||||
const assistantMessageCalls = vi.hoisted(
|
|
||||||
() => [] as Array<{ message: string; spacing: string | undefined }>,
|
|
||||||
);
|
|
||||||
const turnCopyButtonCalls = vi.hoisted(() => [] as Array<{ getContent: () => string }>);
|
|
||||||
|
|
||||||
const mockSessionState = vi.hoisted(() => ({
|
|
||||||
sessions: {
|
|
||||||
server: {
|
|
||||||
client: null,
|
|
||||||
agentStreamHead: new Map<string, StreamItem[]>(),
|
|
||||||
workspaces: new Map(),
|
|
||||||
agentTimelineCursor: new Map(),
|
|
||||||
agentTimelineHasOlder: new Map(),
|
|
||||||
agentTimelineOlderFetchInFlight: new Map(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setAgentTimelineOlderFetchInFlight: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (
|
|
||||||
factory: (theme: {
|
|
||||||
borderRadius: Record<string, number>;
|
|
||||||
borderWidth: Record<number, number>;
|
|
||||||
colors: Record<string, string>;
|
|
||||||
fontSize: Record<string, number>;
|
|
||||||
fontWeight: Record<string, string>;
|
|
||||||
shadow: Record<string, object>;
|
|
||||||
spacing: Record<number, number>;
|
|
||||||
}) => unknown,
|
|
||||||
) =>
|
|
||||||
factory({
|
|
||||||
borderRadius: {
|
|
||||||
full: 9999,
|
|
||||||
md: 6,
|
|
||||||
},
|
|
||||||
borderWidth: {
|
|
||||||
1: 1,
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
surface0: "#000",
|
|
||||||
surface1: "#111",
|
|
||||||
surface2: "#222",
|
|
||||||
border: "#333",
|
|
||||||
borderAccent: "#444",
|
|
||||||
},
|
|
||||||
fontSize: {
|
|
||||||
sm: 14,
|
|
||||||
base: 16,
|
|
||||||
xs: 12,
|
|
||||||
},
|
|
||||||
fontWeight: {
|
|
||||||
normal: "normal",
|
|
||||||
},
|
|
||||||
shadow: {
|
|
||||||
sm: {},
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
1: 4,
|
|
||||||
2: 8,
|
|
||||||
3: 12,
|
|
||||||
4: 16,
|
|
||||||
12: 48,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
|
|
||||||
withUnistyles: (Component: unknown) => Component,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-reanimated", async () => {
|
|
||||||
const ReactModule = await import("react");
|
|
||||||
return {
|
|
||||||
default: {
|
|
||||||
View: ({ children, ...props }: { children?: React.ReactNode }) =>
|
|
||||||
ReactModule.createElement("div", props, children),
|
|
||||||
},
|
|
||||||
Easing: { linear: vi.fn() },
|
|
||||||
FadeIn: { duration: vi.fn(() => undefined) },
|
|
||||||
FadeOut: { duration: vi.fn(() => undefined) },
|
|
||||||
cancelAnimation: vi.fn(),
|
|
||||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
|
||||||
useSharedValue: (value: unknown) => ({ value }),
|
|
||||||
withRepeat: (value: unknown) => value,
|
|
||||||
withTiming: (value: unknown) => value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", async () => {
|
|
||||||
const ReactModule = await import("react");
|
|
||||||
const Icon = () => ReactModule.createElement("span");
|
|
||||||
return {
|
|
||||||
Check: Icon,
|
|
||||||
ChevronDown: Icon,
|
|
||||||
X: Icon,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./message", async () => {
|
|
||||||
const ReactModule = await import("react");
|
|
||||||
return {
|
|
||||||
ActivityLog: () => null,
|
|
||||||
AssistantMessage: (props: { message: string; spacing?: string }) => {
|
|
||||||
assistantMessageCalls.push({ message: props.message, spacing: props.spacing });
|
|
||||||
return ReactModule.createElement("div", {
|
|
||||||
"data-message": props.message,
|
|
||||||
"data-spacing": props.spacing ?? "",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
CompactionMarker: () => null,
|
|
||||||
MessageOuterSpacingProvider: ({ children }: { children: React.ReactNode }) =>
|
|
||||||
ReactModule.createElement(ReactModule.Fragment, null, children),
|
|
||||||
SpeakMessage: () => null,
|
|
||||||
TodoListCard: () => null,
|
|
||||||
ToolCall: () => null,
|
|
||||||
TurnCopyButton: (props: { getContent: () => string }) => {
|
|
||||||
turnCopyButtonCalls.push(props);
|
|
||||||
return ReactModule.createElement("button", {
|
|
||||||
"data-testid": "turn-copy-button",
|
|
||||||
type: "button",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
UserMessage: () => null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./tool-call-sheet", async () => {
|
|
||||||
const ReactModule = await import("react");
|
|
||||||
return {
|
|
||||||
ToolCallSheetProvider: ({ children }: { children: React.ReactNode }) =>
|
|
||||||
ReactModule.createElement(ReactModule.Fragment, null, children),
|
|
||||||
useToolCallSheet: () => ({ open: vi.fn() }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./tool-call-details", () => ({ ToolCallDetailsContent: () => null }));
|
|
||||||
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
|
|
||||||
vi.mock("./question-form-card", () => ({ QuestionFormCard: () => null }));
|
|
||||||
vi.mock("./plan-card", () => ({ PlanCard: () => null }));
|
|
||||||
vi.mock("@/hooks/use-file-explorer-actions", () => ({
|
|
||||||
useFileExplorerActions: () => ({ requestDirectoryListing: vi.fn() }),
|
|
||||||
}));
|
|
||||||
vi.mock("@/stores/panel-store", () => ({
|
|
||||||
usePanelStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
|
||||||
selector({
|
|
||||||
openFileExplorerForCheckout: vi.fn(),
|
|
||||||
setExplorerTabForCheckout: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("@/stores/session-store", () => ({
|
|
||||||
useSessionStore: Object.assign(
|
|
||||||
(selector: (state: typeof mockSessionState) => unknown) => selector(mockSessionState),
|
|
||||||
{
|
|
||||||
getState: () => mockSessionState,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("expo-router", () => ({ useRouter: () => ({ navigate: vi.fn() }) }));
|
|
||||||
|
|
||||||
function assistantBlock(params: {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
blockIndex: number;
|
|
||||||
}): Extract<StreamItem, { kind: "assistant_message" }> {
|
|
||||||
return {
|
|
||||||
kind: "assistant_message",
|
|
||||||
id: params.id,
|
|
||||||
blockGroupId: "group-1",
|
|
||||||
blockIndex: params.blockIndex,
|
|
||||||
text: params.text,
|
|
||||||
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function runningToolCall(id: string): Extract<StreamItem, { kind: "tool_call" }> {
|
|
||||||
return {
|
|
||||||
kind: "tool_call",
|
|
||||||
id,
|
|
||||||
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
|
||||||
payload: {
|
|
||||||
source: "orchestrator",
|
|
||||||
data: {
|
|
||||||
toolCallId: id,
|
|
||||||
toolName: "bash",
|
|
||||||
arguments: "npm test",
|
|
||||||
result: null,
|
|
||||||
status: "executing",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("AgentStreamView", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLDivElement | null = null;
|
|
||||||
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
||||||
value: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
originalScrollTo = HTMLElement.prototype.scrollTo;
|
|
||||||
HTMLElement.prototype.scrollTo = vi.fn();
|
|
||||||
assistantMessageCalls.length = 0;
|
|
||||||
turnCopyButtonCalls.length = 0;
|
|
||||||
mockSessionState.sessions.server.agentStreamHead = new Map();
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
if (originalScrollTo) {
|
|
||||||
HTMLElement.prototype.scrollTo = originalScrollTo;
|
|
||||||
} else {
|
|
||||||
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
|
|
||||||
}
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("compacts assistant block spacing across the history/live-head boundary", () => {
|
|
||||||
const tailBlock = assistantBlock({
|
|
||||||
id: "group-1:block:0",
|
|
||||||
text: "First paragraph",
|
|
||||||
blockIndex: 0,
|
|
||||||
});
|
|
||||||
const headBlock = assistantBlock({
|
|
||||||
id: "group-1:head",
|
|
||||||
text: "Second paragraph",
|
|
||||||
blockIndex: 1,
|
|
||||||
});
|
|
||||||
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
|
|
||||||
const agent = {
|
|
||||||
id: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
status: "idle",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
} as never;
|
|
||||||
const streamItems = [tailBlock];
|
|
||||||
const pendingPermissions = new Map();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
React.createElement(AgentStreamView, {
|
|
||||||
agentId: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
agent,
|
|
||||||
streamItems,
|
|
||||||
pendingPermissions,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const tailCalls = assistantMessageCalls.filter((call) => call.message === "First paragraph");
|
|
||||||
const headCalls = assistantMessageCalls.filter((call) => call.message === "Second paragraph");
|
|
||||||
|
|
||||||
expect(tailCalls.length).toBeGreaterThan(0);
|
|
||||||
expect(headCalls.length).toBeGreaterThan(0);
|
|
||||||
expect(tailCalls.map((call) => call.spacing)).toEqual(
|
|
||||||
Array.from({ length: tailCalls.length }, () => "compactBottom"),
|
|
||||||
);
|
|
||||||
expect(headCalls.map((call) => call.spacing)).toEqual(
|
|
||||||
Array.from({ length: headCalls.length }, () => "compactTop"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders running dots in the assistant turn footer when live text is streaming", () => {
|
|
||||||
const headBlock = assistantBlock({
|
|
||||||
id: "group-1:head",
|
|
||||||
text: "Streaming paragraph",
|
|
||||||
blockIndex: 0,
|
|
||||||
});
|
|
||||||
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
|
|
||||||
const agent = {
|
|
||||||
id: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
status: "running",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
React.createElement(AgentStreamView, {
|
|
||||||
agentId: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
agent,
|
|
||||||
streamItems: [],
|
|
||||||
pendingPermissions: new Map(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).not.toBeNull();
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
|
|
||||||
).toBeNull();
|
|
||||||
expect(container?.querySelector('[data-testid="turn-copy-button"]')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only renders running dots on the live assistant row", () => {
|
|
||||||
const tailBlock = assistantBlock({
|
|
||||||
id: "group-1:block:0",
|
|
||||||
text: "History paragraph",
|
|
||||||
blockIndex: 0,
|
|
||||||
});
|
|
||||||
const headBlock = assistantBlock({
|
|
||||||
id: "group-2:head",
|
|
||||||
text: "Streaming paragraph",
|
|
||||||
blockIndex: 0,
|
|
||||||
});
|
|
||||||
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
|
|
||||||
const agent = {
|
|
||||||
id: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
status: "running",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
React.createElement(AgentStreamView, {
|
|
||||||
agentId: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
agent,
|
|
||||||
streamItems: [tailBlock],
|
|
||||||
pendingPermissions: new Map(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelectorAll('[data-testid="turn-working-indicator"]')).toHaveLength(1);
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the auxiliary running dots when there is no live assistant row", () => {
|
|
||||||
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [runningToolCall("tool-1")]);
|
|
||||||
const agent = {
|
|
||||||
id: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
status: "running",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
React.createElement(AgentStreamView, {
|
|
||||||
agentId: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
agent,
|
|
||||||
streamItems: [],
|
|
||||||
pendingPermissions: new Map(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
|
|
||||||
expect(
|
|
||||||
container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'),
|
|
||||||
).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces the running footer with the copy button when the assistant turn idles", () => {
|
|
||||||
const headBlock = assistantBlock({
|
|
||||||
id: "group-1:head",
|
|
||||||
text: "Complete paragraph",
|
|
||||||
blockIndex: 0,
|
|
||||||
});
|
|
||||||
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
|
|
||||||
const agent = {
|
|
||||||
id: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
status: "idle",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
React.createElement(AgentStreamView, {
|
|
||||||
agentId: "agent-1",
|
|
||||||
serverId: "server",
|
|
||||||
agent,
|
|
||||||
streamItems: [],
|
|
||||||
pendingPermissions: new Map(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull();
|
|
||||||
expect(container?.querySelector('[data-testid="turn-copy-button"]')).not.toBeNull();
|
|
||||||
expect(turnCopyButtonCalls.length).toBeGreaterThan(0);
|
|
||||||
expect(turnCopyButtonCalls.map((call) => call.getContent())).toEqual(
|
|
||||||
Array.from({ length: turnCopyButtonCalls.length }, () => "Complete paragraph"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -71,6 +71,11 @@ import {
|
|||||||
type StreamSegmentRenderers,
|
type StreamSegmentRenderers,
|
||||||
type StreamViewportHandle,
|
type StreamViewportHandle,
|
||||||
} from "./agent-stream-render-strategy";
|
} from "./agent-stream-render-strategy";
|
||||||
|
import {
|
||||||
|
getAssistantBlockSpacing,
|
||||||
|
isSameAssistantBlockGroup,
|
||||||
|
resolveInlineWorkingIndicatorItemId,
|
||||||
|
} from "./agent-stream-view-data";
|
||||||
import {
|
import {
|
||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
type BottomAnchorRouteRequest,
|
type BottomAnchorRouteRequest,
|
||||||
@@ -92,43 +97,6 @@ const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
|||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||||
|
|
||||||
const isSameAssistantBlockGroup = (params: {
|
|
||||||
item: StreamItem | null | undefined;
|
|
||||||
other: StreamItem | null | undefined;
|
|
||||||
}) =>
|
|
||||||
params.item?.kind === "assistant_message" &&
|
|
||||||
params.other?.kind === "assistant_message" &&
|
|
||||||
params.item.blockGroupId !== undefined &&
|
|
||||||
params.item.blockGroupId === params.other.blockGroupId;
|
|
||||||
|
|
||||||
const getAssistantBlockSpacing = (params: {
|
|
||||||
item: StreamItem;
|
|
||||||
aboveItem: StreamItem | null | undefined;
|
|
||||||
belowItem: StreamItem | null | undefined;
|
|
||||||
}): "default" | "compactTop" | "compactBottom" | "compactBoth" => {
|
|
||||||
if (params.item.kind !== "assistant_message") {
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
const compactTop = isSameAssistantBlockGroup({
|
|
||||||
item: params.item,
|
|
||||||
other: params.aboveItem,
|
|
||||||
});
|
|
||||||
const compactBottom = isSameAssistantBlockGroup({
|
|
||||||
item: params.item,
|
|
||||||
other: params.belowItem,
|
|
||||||
});
|
|
||||||
if (compactTop && compactBottom) {
|
|
||||||
return "compactBoth";
|
|
||||||
}
|
|
||||||
if (compactTop) {
|
|
||||||
return "compactTop";
|
|
||||||
}
|
|
||||||
if (compactBottom) {
|
|
||||||
return "compactBottom";
|
|
||||||
}
|
|
||||||
return "default";
|
|
||||||
};
|
|
||||||
|
|
||||||
interface StreamItemBoundarySeams {
|
interface StreamItemBoundarySeams {
|
||||||
aboveItem?: StreamItem | null;
|
aboveItem?: StreamItem | null;
|
||||||
belowItem?: StreamItem | null;
|
belowItem?: StreamItem | null;
|
||||||
@@ -301,25 +269,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
isMobileBreakpoint: isMobile,
|
isMobileBreakpoint: isMobile,
|
||||||
});
|
});
|
||||||
}, [isMobile, streamHead, streamItems]);
|
}, [isMobile, streamHead, streamItems]);
|
||||||
const inlineWorkingIndicatorItemId = useMemo(() => {
|
const inlineWorkingIndicatorItemId = useMemo(
|
||||||
if (agent.status !== "running") {
|
() =>
|
||||||
return null;
|
resolveInlineWorkingIndicatorItemId(
|
||||||
}
|
agent.status,
|
||||||
const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => {
|
baseRenderModel.segments.liveHead,
|
||||||
if (item.kind !== "assistant_message") {
|
streamRenderStrategy,
|
||||||
return false;
|
),
|
||||||
}
|
[agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy],
|
||||||
return (
|
);
|
||||||
getStreamNeighborItem({
|
|
||||||
strategy: streamRenderStrategy,
|
|
||||||
items,
|
|
||||||
index,
|
|
||||||
relation: "below",
|
|
||||||
}) === undefined
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return footerItem?.id ?? null;
|
|
||||||
}, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]);
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -0,0 +1,715 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||||
|
import type {
|
||||||
|
AttachmentMetadata,
|
||||||
|
ComposerAttachment,
|
||||||
|
UserComposerAttachment,
|
||||||
|
WorkspaceComposerAttachment,
|
||||||
|
} from "@/attachments/types";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import {
|
||||||
|
cancelComposerAgent,
|
||||||
|
dispatchComposerAgentMessage,
|
||||||
|
editQueuedComposerMessage,
|
||||||
|
findGithubItemByOption,
|
||||||
|
isAttachmentSelectedForGithubItem,
|
||||||
|
openComposerAttachment,
|
||||||
|
pickAndPersistImages,
|
||||||
|
queueComposerMessage,
|
||||||
|
removeComposerAttachmentAtIndex,
|
||||||
|
sendQueuedComposerMessageNow,
|
||||||
|
toggleGithubAttachment,
|
||||||
|
type AgentStreamWriter,
|
||||||
|
type AttachmentPersister,
|
||||||
|
type ComposerCancelClient,
|
||||||
|
type ComposerSendClient,
|
||||||
|
type QueueWriter,
|
||||||
|
type QueuedComposerMessage,
|
||||||
|
} from "./composer-actions";
|
||||||
|
|
||||||
|
const imageMetadata: AttachmentMetadata = {
|
||||||
|
id: "img-1",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "img-1",
|
||||||
|
fileName: "img-1.png",
|
||||||
|
byteSize: 42,
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueItem: GitHubSearchItem = {
|
||||||
|
kind: "issue",
|
||||||
|
number: 101,
|
||||||
|
title: "Fix composer attachments",
|
||||||
|
url: "https://github.com/acme/paseo/issues/101",
|
||||||
|
state: "open",
|
||||||
|
body: "Issue body",
|
||||||
|
labels: ["composer"],
|
||||||
|
baseRefName: null,
|
||||||
|
headRefName: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const prItem: GitHubSearchItem = {
|
||||||
|
kind: "pr",
|
||||||
|
number: 202,
|
||||||
|
title: "Refactor composer attachments",
|
||||||
|
url: "https://github.com/acme/paseo/pull/202",
|
||||||
|
state: "open",
|
||||||
|
body: "PR body",
|
||||||
|
labels: ["composer"],
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "composer-attachments",
|
||||||
|
};
|
||||||
|
|
||||||
|
function imageWithId(id: string): AttachmentMetadata {
|
||||||
|
return { ...imageMetadata, id, storageKey: id, fileName: `${id}.png` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewWorkspaceAttachment(body: string): WorkspaceComposerAttachment {
|
||||||
|
const attachment: Extract<AgentAttachment, { type: "review" }> = {
|
||||||
|
type: "review",
|
||||||
|
mimeType: "application/paseo-review",
|
||||||
|
cwd: "/repo",
|
||||||
|
mode: "uncommitted",
|
||||||
|
baseRef: null,
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
filePath: "src/example.ts",
|
||||||
|
side: "new",
|
||||||
|
lineNumber: 41,
|
||||||
|
body,
|
||||||
|
context: {
|
||||||
|
hunkHeader: "@@ -40,2 +40,2 @@",
|
||||||
|
targetLine: {
|
||||||
|
oldLineNumber: null,
|
||||||
|
newLineNumber: 41,
|
||||||
|
type: "add",
|
||||||
|
content: "const value = newValue;",
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
oldLineNumber: null,
|
||||||
|
newLineNumber: 41,
|
||||||
|
type: "add",
|
||||||
|
content: "const value = newValue;",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
kind: "review",
|
||||||
|
reviewDraftKey: `review:${body}`,
|
||||||
|
commentCount: 1,
|
||||||
|
attachment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserElementWorkspaceAttachment(): Extract<
|
||||||
|
WorkspaceComposerAttachment,
|
||||||
|
{ kind: "browser_element" }
|
||||||
|
> {
|
||||||
|
return {
|
||||||
|
kind: "browser_element",
|
||||||
|
attachment: {
|
||||||
|
url: "https://example.com/page",
|
||||||
|
selector: "button.primary",
|
||||||
|
tag: "button",
|
||||||
|
text: "Save",
|
||||||
|
outerHTML: '<button class="primary">Save</button>',
|
||||||
|
computedStyles: { display: "flex" },
|
||||||
|
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
|
||||||
|
reactSource: null,
|
||||||
|
parentChain: ["form.settings"],
|
||||||
|
children: [],
|
||||||
|
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakePersister(): AttachmentPersister & {
|
||||||
|
blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }>;
|
||||||
|
fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }>;
|
||||||
|
deletedBatches: AttachmentMetadata[][];
|
||||||
|
} {
|
||||||
|
const blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }> = [];
|
||||||
|
const fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }> = [];
|
||||||
|
const deletedBatches: AttachmentMetadata[][] = [];
|
||||||
|
return {
|
||||||
|
blobCalls,
|
||||||
|
fileUriCalls,
|
||||||
|
deletedBatches,
|
||||||
|
persistFromBlob: async ({ blob, mimeType, fileName }) => {
|
||||||
|
blobCalls.push({ blob, mimeType, fileName });
|
||||||
|
return { ...imageMetadata, id: `blob-${blobCalls.length}` };
|
||||||
|
},
|
||||||
|
persistFromFileUri: async ({ uri, mimeType, fileName }) => {
|
||||||
|
fileUriCalls.push({ uri, mimeType, fileName });
|
||||||
|
return { ...imageMetadata, id: `uri-${fileUriCalls.length}` };
|
||||||
|
},
|
||||||
|
deleteAttachments: (metadata) => {
|
||||||
|
deletedBatches.push(metadata);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeSendCall {
|
||||||
|
agentId: string;
|
||||||
|
text: string;
|
||||||
|
options: {
|
||||||
|
messageId: string;
|
||||||
|
images: Array<{ data: string; mimeType: string }>;
|
||||||
|
attachments: AgentAttachment[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeSendClient(
|
||||||
|
options: { rejection?: Error } = {},
|
||||||
|
): ComposerSendClient & { calls: FakeSendCall[] } {
|
||||||
|
const calls: FakeSendCall[] = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
sendAgentMessage: async (agentId, text, opts) => {
|
||||||
|
calls.push({ agentId, text, options: opts });
|
||||||
|
if (options.rejection) {
|
||||||
|
throw options.rejection;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeStream extends AgentStreamWriter {
|
||||||
|
head: Map<string, StreamItem[]>;
|
||||||
|
tail: Map<string, StreamItem[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): FakeStream {
|
||||||
|
const fake: FakeStream = {
|
||||||
|
head: new Map(initialHead),
|
||||||
|
tail: new Map(),
|
||||||
|
getHead: (agentId) => fake.head.get(agentId),
|
||||||
|
setHead: (updater) => {
|
||||||
|
fake.head = updater(fake.head);
|
||||||
|
},
|
||||||
|
setTail: (updater) => {
|
||||||
|
fake.tail = updater(fake.tail);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeQueue(
|
||||||
|
initial: Map<string, QueuedComposerMessage[]> = new Map(),
|
||||||
|
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
|
||||||
|
const fake: QueueWriter & { state: Map<string, QueuedComposerMessage[]> } = {
|
||||||
|
state: new Map(initial),
|
||||||
|
read: (agentId) => fake.state.get(agentId) ?? [],
|
||||||
|
write: (updater) => {
|
||||||
|
fake.state = updater(fake.state);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passthroughEncodeImages = async (images: AttachmentMetadata[]) =>
|
||||||
|
images.map((image) => ({ data: image.id, mimeType: image.mimeType }));
|
||||||
|
|
||||||
|
describe("cancelComposerAgent", () => {
|
||||||
|
function baseInput(): {
|
||||||
|
client: ComposerCancelClient & { canceledIds: string[] };
|
||||||
|
agentId: string;
|
||||||
|
isAgentRunning: boolean;
|
||||||
|
isCancellingAgent: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
} {
|
||||||
|
const canceledIds: string[] = [];
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
canceledIds,
|
||||||
|
cancelAgent: async (id) => {
|
||||||
|
canceledIds.push(id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentId: "agent",
|
||||||
|
isAgentRunning: true,
|
||||||
|
isCancellingAgent: false,
|
||||||
|
isConnected: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("issues a cancel and reports true when the agent is running, connected, and not already canceling", () => {
|
||||||
|
const input = baseInput();
|
||||||
|
const result = cancelComposerAgent(input);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(input.client.canceledIds).toEqual(["agent"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the agent is not running", () => {
|
||||||
|
const input = baseInput();
|
||||||
|
const result = cancelComposerAgent({ ...input, isAgentRunning: false });
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(input.client.canceledIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the agent is already being canceled", () => {
|
||||||
|
const input = baseInput();
|
||||||
|
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(input.client.canceledIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when disconnected or the client is null", () => {
|
||||||
|
const input = baseInput();
|
||||||
|
expect(cancelComposerAgent({ ...input, isConnected: false })).toBe(false);
|
||||||
|
expect(cancelComposerAgent({ ...input, client: null })).toBe(false);
|
||||||
|
expect(input.client.canceledIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickAndPersistImages", () => {
|
||||||
|
it("returns [] when the picker yields nothing", async () => {
|
||||||
|
const persister = createFakePersister();
|
||||||
|
const result = await pickAndPersistImages({
|
||||||
|
pickImages: async () => null,
|
||||||
|
persister,
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(persister.blobCalls).toEqual([]);
|
||||||
|
expect(persister.fileUriCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists blob sources via persistFromBlob with the picked mime type and file name", async () => {
|
||||||
|
const persister = createFakePersister();
|
||||||
|
const blob = new Blob(["image"]);
|
||||||
|
const result = await pickAndPersistImages({
|
||||||
|
pickImages: async () => [
|
||||||
|
{ source: { kind: "blob", blob }, mimeType: "image/png", fileName: "img-1.png" },
|
||||||
|
],
|
||||||
|
persister,
|
||||||
|
});
|
||||||
|
expect(persister.blobCalls).toEqual([{ blob, mimeType: "image/png", fileName: "img-1.png" }]);
|
||||||
|
expect(result.map((m) => m.id)).toEqual(["blob-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists file_uri sources via persistFromFileUri", async () => {
|
||||||
|
const persister = createFakePersister();
|
||||||
|
const result = await pickAndPersistImages({
|
||||||
|
pickImages: async () => [
|
||||||
|
{ source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null },
|
||||||
|
],
|
||||||
|
persister,
|
||||||
|
});
|
||||||
|
expect(persister.fileUriCalls).toEqual([
|
||||||
|
{ uri: "/tmp/x.jpg", mimeType: "image/jpeg", fileName: null },
|
||||||
|
]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dispatchComposerAgentMessage", () => {
|
||||||
|
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
const stream = createFakeStream();
|
||||||
|
const image = imageWithId("img-2");
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "send attachments",
|
||||||
|
attachments: [
|
||||||
|
{ kind: "image", metadata: image },
|
||||||
|
{ kind: "github_pr", item: prItem },
|
||||||
|
],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.calls).toHaveLength(1);
|
||||||
|
const [call] = client.calls;
|
||||||
|
expect(call.agentId).toBe("agent");
|
||||||
|
expect(call.text).toBe("send attachments");
|
||||||
|
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
|
||||||
|
expect(call.options.attachments).toEqual([
|
||||||
|
{
|
||||||
|
type: "github_pr",
|
||||||
|
mimeType: "application/github-pr",
|
||||||
|
number: 202,
|
||||||
|
title: "Refactor composer attachments",
|
||||||
|
url: "https://github.com/acme/paseo/pull/202",
|
||||||
|
body: "PR body",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "composer-attachments",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(stream.head.get("agent")).toBeUndefined();
|
||||||
|
const tail = stream.tail.get("agent");
|
||||||
|
expect(tail).toHaveLength(1);
|
||||||
|
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
|
||||||
|
expect(userMessage.kind).toBe("user_message");
|
||||||
|
expect(userMessage.text).toBe("send attachments");
|
||||||
|
expect(userMessage.images).toEqual([image]);
|
||||||
|
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||||
|
expect(userMessage.id).toBe(call.options.messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends to the existing head when one is present", async () => {
|
||||||
|
const existingItem: StreamItem = {
|
||||||
|
kind: "user_message",
|
||||||
|
id: "prior",
|
||||||
|
text: "prior",
|
||||||
|
timestamp: new Date(0),
|
||||||
|
};
|
||||||
|
const stream = createFakeStream(new Map([["agent", [existingItem]]]));
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "next message",
|
||||||
|
attachments: [],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stream.head.get("agent")).toHaveLength(2);
|
||||||
|
expect(stream.tail.get("agent")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits empty wire arrays when no attachments are provided", async () => {
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
const stream = createFakeStream();
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "plain message",
|
||||||
|
attachments: [],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.calls[0]?.options).toMatchObject({
|
||||||
|
images: [],
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes workspace review attachments through the structured attachment path", async () => {
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
const stream = createFakeStream();
|
||||||
|
const review = reviewWorkspaceAttachment("Please simplify this.");
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "review this",
|
||||||
|
attachments: [review],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
|
||||||
|
expect(client.calls[0]?.options.images).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => {
|
||||||
|
const client = createFakeSendClient();
|
||||||
|
const stream = createFakeStream();
|
||||||
|
const browserElement = browserElementWorkspaceAttachment();
|
||||||
|
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
|
client,
|
||||||
|
agentId: "agent",
|
||||||
|
text: "inspect element",
|
||||||
|
attachments: [browserElement],
|
||||||
|
encodeImages: passthroughEncodeImages,
|
||||||
|
stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.calls[0]?.options.attachments).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
title: "Browser element · button",
|
||||||
|
text: browserElement.attachment.formatted,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("queueComposerMessage", () => {
|
||||||
|
it("queues a trimmed message under the agent id and returns the new entry", () => {
|
||||||
|
const queue = createFakeQueue();
|
||||||
|
const result = queueComposerMessage({
|
||||||
|
agentId: "agent",
|
||||||
|
text: " draft ",
|
||||||
|
attachments: [],
|
||||||
|
queue,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.queued?.text).toBe("draft");
|
||||||
|
expect(queue.state.get("agent")).toEqual([
|
||||||
|
{ id: result.queued?.id, text: "draft", attachments: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not queue an empty message with no attachments", () => {
|
||||||
|
const queue = createFakeQueue();
|
||||||
|
const result = queueComposerMessage({
|
||||||
|
agentId: "agent",
|
||||||
|
text: " ",
|
||||||
|
attachments: [],
|
||||||
|
queue,
|
||||||
|
});
|
||||||
|
expect(result.queued).toBeNull();
|
||||||
|
expect(queue.state.get("agent")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures workspace review attachments at queue time alongside user attachments", () => {
|
||||||
|
const queue = createFakeQueue();
|
||||||
|
const review = reviewWorkspaceAttachment("Initial queued review.");
|
||||||
|
const image = imageWithId("img-queue");
|
||||||
|
queueComposerMessage({
|
||||||
|
agentId: "agent",
|
||||||
|
text: "queue this",
|
||||||
|
attachments: [{ kind: "image", metadata: image }, review],
|
||||||
|
queue,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queue.state.get("agent")?.[0]?.attachments).toEqual([
|
||||||
|
{ kind: "image", metadata: image },
|
||||||
|
review,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editQueuedComposerMessage", () => {
|
||||||
|
it("returns null and leaves the queue untouched when the message id is missing", () => {
|
||||||
|
const queue = createFakeQueue(
|
||||||
|
new Map([["agent", [{ id: "other", text: "other", attachments: [] }]]]),
|
||||||
|
);
|
||||||
|
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "missing", queue });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(queue.state.get("agent")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the text and only user attachments, removing the queued entry", () => {
|
||||||
|
const review = reviewWorkspaceAttachment("Queued snapshot.");
|
||||||
|
const image = imageWithId("img-queued-edit");
|
||||||
|
const queue = createFakeQueue(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
"agent",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
text: "queued draft",
|
||||||
|
attachments: [{ kind: "image", metadata: image }, review],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "msg-1", queue });
|
||||||
|
expect(result).toEqual({
|
||||||
|
text: "queued draft",
|
||||||
|
attachments: [{ kind: "image", metadata: image }],
|
||||||
|
});
|
||||||
|
expect(queue.state.get("agent")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendQueuedComposerMessageNow", () => {
|
||||||
|
it("returns missing without submitting when the message id is gone", async () => {
|
||||||
|
const queue = createFakeQueue();
|
||||||
|
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||||
|
const result = await sendQueuedComposerMessageNow({
|
||||||
|
agentId: "agent",
|
||||||
|
messageId: "msg-1",
|
||||||
|
queue,
|
||||||
|
submitMessage: async (input) => {
|
||||||
|
submitted.push(input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ status: "missing" });
|
||||||
|
expect(submitted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the queued entry and submits its text + attachments", async () => {
|
||||||
|
const review = reviewWorkspaceAttachment("Queued for send.");
|
||||||
|
const queue = createFakeQueue(
|
||||||
|
new Map([["agent", [{ id: "msg-1", text: "send me", attachments: [review] }]]]),
|
||||||
|
);
|
||||||
|
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||||
|
const result = await sendQueuedComposerMessageNow({
|
||||||
|
agentId: "agent",
|
||||||
|
messageId: "msg-1",
|
||||||
|
queue,
|
||||||
|
submitMessage: async (input) => {
|
||||||
|
submitted.push(input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ status: "submitted" });
|
||||||
|
expect(queue.state.get("agent")).toEqual([]);
|
||||||
|
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
|
||||||
|
const queue = createFakeQueue(
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
"agent",
|
||||||
|
[
|
||||||
|
{ id: "msg-1", text: "first", attachments: [] },
|
||||||
|
{ id: "msg-2", text: "second", attachments: [] },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const result = await sendQueuedComposerMessageNow({
|
||||||
|
agentId: "agent",
|
||||||
|
messageId: "msg-1",
|
||||||
|
queue,
|
||||||
|
submitMessage: async () => {
|
||||||
|
throw new Error("network down");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ status: "failed", errorMessage: "network down" });
|
||||||
|
const state = queue.state.get("agent");
|
||||||
|
expect(state?.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeComposerAttachmentAtIndex", () => {
|
||||||
|
it("removes an image attachment and asks the persister to delete the underlying metadata", () => {
|
||||||
|
const image = imageWithId("img-remove");
|
||||||
|
const persister = createFakePersister();
|
||||||
|
const next = removeComposerAttachmentAtIndex({
|
||||||
|
attachments: [{ kind: "image", metadata: image }] satisfies UserComposerAttachment[],
|
||||||
|
index: 0,
|
||||||
|
deleteAttachments: persister.deleteAttachments,
|
||||||
|
});
|
||||||
|
expect(next).toEqual([]);
|
||||||
|
expect(persister.deletedBatches).toEqual([[image]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a github attachment without scheduling any storage deletes", () => {
|
||||||
|
const persister = createFakePersister();
|
||||||
|
const next = removeComposerAttachmentAtIndex({
|
||||||
|
attachments: [
|
||||||
|
{ kind: "github_issue", item: issueItem },
|
||||||
|
{ kind: "github_pr", item: prItem },
|
||||||
|
] satisfies UserComposerAttachment[],
|
||||||
|
index: 0,
|
||||||
|
deleteAttachments: persister.deleteAttachments,
|
||||||
|
});
|
||||||
|
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||||
|
expect(persister.deletedBatches).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openComposerAttachment", () => {
|
||||||
|
it("opens the lightbox for image attachments", () => {
|
||||||
|
const image = imageWithId("img-body");
|
||||||
|
const lightboxCalls: AttachmentMetadata[] = [];
|
||||||
|
const externalUrlCalls: string[] = [];
|
||||||
|
openComposerAttachment({
|
||||||
|
attachment: { kind: "image", metadata: image },
|
||||||
|
setLightboxMetadata: (metadata) => {
|
||||||
|
lightboxCalls.push(metadata);
|
||||||
|
},
|
||||||
|
openWorkspaceAttachment: () => false,
|
||||||
|
openExternalUrl: (url) => {
|
||||||
|
externalUrlCalls.push(url);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(lightboxCalls).toEqual([image]);
|
||||||
|
expect(externalUrlCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates workspace review attachments to the workspace opener", () => {
|
||||||
|
const review = reviewWorkspaceAttachment("Open me.");
|
||||||
|
const workspaceCalls: ComposerAttachment[] = [];
|
||||||
|
openComposerAttachment({
|
||||||
|
attachment: review,
|
||||||
|
setLightboxMetadata: () => {
|
||||||
|
throw new Error("unexpected lightbox call");
|
||||||
|
},
|
||||||
|
openWorkspaceAttachment: ({ attachment }) => {
|
||||||
|
workspaceCalls.push(attachment);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
openExternalUrl: () => {
|
||||||
|
throw new Error("unexpected external url call");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(workspaceCalls).toEqual([review]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens GitHub item URLs through the external url opener", () => {
|
||||||
|
const externalUrlCalls: string[] = [];
|
||||||
|
openComposerAttachment({
|
||||||
|
attachment: { kind: "github_issue", item: issueItem },
|
||||||
|
setLightboxMetadata: () => {
|
||||||
|
throw new Error("unexpected lightbox call");
|
||||||
|
},
|
||||||
|
openWorkspaceAttachment: () => false,
|
||||||
|
openExternalUrl: (url) => {
|
||||||
|
externalUrlCalls.push(url);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(externalUrlCalls).toEqual([issueItem.url]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleGithubAttachment", () => {
|
||||||
|
it("appends a GitHub issue when not already attached", () => {
|
||||||
|
const next = toggleGithubAttachment([], issueItem);
|
||||||
|
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a GitHub PR when not already attached", () => {
|
||||||
|
const next = toggleGithubAttachment([], prItem);
|
||||||
|
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an existing GitHub item with the same kind+number", () => {
|
||||||
|
const next = toggleGithubAttachment([{ kind: "github_issue", item: issueItem }], issueItem);
|
||||||
|
expect(next).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not affect other items with different kind or number", () => {
|
||||||
|
const start: UserComposerAttachment[] = [
|
||||||
|
{ kind: "github_issue", item: issueItem },
|
||||||
|
{ kind: "github_pr", item: prItem },
|
||||||
|
];
|
||||||
|
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
|
||||||
|
const next = toggleGithubAttachment(start, otherIssue);
|
||||||
|
expect(next).toEqual([
|
||||||
|
{ kind: "github_issue", item: issueItem },
|
||||||
|
{ kind: "github_pr", item: prItem },
|
||||||
|
{ kind: "github_issue", item: otherIssue },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
|
||||||
|
it("locates items via their composite kind:number id", () => {
|
||||||
|
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
|
||||||
|
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
|
||||||
|
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes when an attachment list already contains a matching GitHub item", () => {
|
||||||
|
const attachments: ComposerAttachment[] = [
|
||||||
|
{ kind: "image", metadata: imageWithId("img-x") },
|
||||||
|
{ kind: "github_issue", item: issueItem },
|
||||||
|
reviewWorkspaceAttachment("ignored"),
|
||||||
|
];
|
||||||
|
expect(isAttachmentSelectedForGithubItem(attachments, issueItem)).toBe(true);
|
||||||
|
expect(isAttachmentSelectedForGithubItem(attachments, prItem)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||||
|
import type {
|
||||||
|
AttachmentMetadata,
|
||||||
|
ComposerAttachment,
|
||||||
|
UserComposerAttachment,
|
||||||
|
} from "@/attachments/types";
|
||||||
|
import {
|
||||||
|
isWorkspaceAttachment,
|
||||||
|
userAttachmentsOnly,
|
||||||
|
} from "@/attachments/workspace-attachment-utils";
|
||||||
|
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||||
|
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||||
|
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||||
|
|
||||||
|
export interface QueuedComposerMessage {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
attachments: ComposerAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentPersister {
|
||||||
|
persistFromBlob: (input: {
|
||||||
|
blob: Blob;
|
||||||
|
mimeType: string;
|
||||||
|
fileName: string | null;
|
||||||
|
}) => Promise<AttachmentMetadata>;
|
||||||
|
persistFromFileUri: (input: {
|
||||||
|
uri: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileName: string | null;
|
||||||
|
}) => Promise<AttachmentMetadata>;
|
||||||
|
deleteAttachments: (metadata: AttachmentMetadata[]) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerSendClient {
|
||||||
|
sendAgentMessage: (
|
||||||
|
agentId: string,
|
||||||
|
text: string,
|
||||||
|
options: {
|
||||||
|
messageId: string;
|
||||||
|
images: Array<{ data: string; mimeType: string }>;
|
||||||
|
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||||
|
},
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposerCancelClient {
|
||||||
|
cancelAgent: (agentId: string) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentStreamWriter {
|
||||||
|
getHead: (agentId: string) => StreamItem[] | undefined;
|
||||||
|
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||||
|
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueWriter {
|
||||||
|
read: (agentId: string) => QueuedComposerMessage[];
|
||||||
|
write: (
|
||||||
|
updater: (prev: Map<string, QueuedComposerMessage[]>) => Map<string, QueuedComposerMessage[]>,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickAndPersistImages(input: {
|
||||||
|
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
||||||
|
persister: Pick<AttachmentPersister, "persistFromBlob" | "persistFromFileUri">;
|
||||||
|
}): Promise<AttachmentMetadata[]> {
|
||||||
|
const result = await input.pickImages();
|
||||||
|
if (!result?.length) return [];
|
||||||
|
return await Promise.all(
|
||||||
|
result.map(async (picked) => {
|
||||||
|
const fileName = picked.fileName ?? null;
|
||||||
|
const mimeType = picked.mimeType || "image/jpeg";
|
||||||
|
if (picked.source.kind === "blob") {
|
||||||
|
return await input.persister.persistFromBlob({
|
||||||
|
blob: picked.source.blob,
|
||||||
|
mimeType,
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await input.persister.persistFromFileUri({
|
||||||
|
uri: picked.source.uri,
|
||||||
|
mimeType,
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(input: {
|
||||||
|
attachments: T[];
|
||||||
|
index: number;
|
||||||
|
deleteAttachments: AttachmentPersister["deleteAttachments"];
|
||||||
|
}): T[] {
|
||||||
|
const removed = input.attachments[input.index];
|
||||||
|
if (removed?.kind === "image") {
|
||||||
|
void input.deleteAttachments([removed.metadata]);
|
||||||
|
}
|
||||||
|
return input.attachments.filter((_, i) => i !== input.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CancelComposerAgentInput {
|
||||||
|
client: ComposerCancelClient | null;
|
||||||
|
agentId: string;
|
||||||
|
isAgentRunning: boolean;
|
||||||
|
isCancellingAgent: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelComposerAgent(input: CancelComposerAgentInput): boolean {
|
||||||
|
if (!input.isAgentRunning || input.isCancellingAgent) return false;
|
||||||
|
if (!input.isConnected || !input.client) return false;
|
||||||
|
void input.client.cancelAgent(input.agentId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DispatchComposerAgentMessageInput {
|
||||||
|
client: ComposerSendClient;
|
||||||
|
agentId: string;
|
||||||
|
text: string;
|
||||||
|
attachments: ComposerAttachment[];
|
||||||
|
encodeImages: (
|
||||||
|
images: AttachmentMetadata[],
|
||||||
|
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||||
|
stream: AgentStreamWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchComposerAgentMessage(
|
||||||
|
input: DispatchComposerAgentMessageInput,
|
||||||
|
): Promise<void> {
|
||||||
|
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
|
||||||
|
const messageId = generateMessageId();
|
||||||
|
const userMessage: StreamItem = {
|
||||||
|
kind: "user_message",
|
||||||
|
id: messageId,
|
||||||
|
text: input.text,
|
||||||
|
timestamp: new Date(),
|
||||||
|
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||||
|
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||||
|
};
|
||||||
|
appendUserMessageToStream(input.agentId, userMessage, input.stream);
|
||||||
|
const imagesData = await input.encodeImages(wirePayload.images);
|
||||||
|
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||||
|
messageId,
|
||||||
|
images: imagesData ?? [],
|
||||||
|
attachments: wirePayload.attachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendUserMessageToStream(
|
||||||
|
agentId: string,
|
||||||
|
userMessage: StreamItem,
|
||||||
|
stream: AgentStreamWriter,
|
||||||
|
): void {
|
||||||
|
const head = stream.getHead(agentId);
|
||||||
|
if (head && head.length > 0) {
|
||||||
|
stream.setHead((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream.setTail((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueComposerMessageInput {
|
||||||
|
agentId: string;
|
||||||
|
text: string;
|
||||||
|
attachments: ComposerAttachment[];
|
||||||
|
queue: QueueWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueComposerMessageResult {
|
||||||
|
queued: QueuedComposerMessage | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueComposerMessage(input: QueueComposerMessageInput): QueueComposerMessageResult {
|
||||||
|
const trimmed = input.text.trim();
|
||||||
|
if (!trimmed && input.attachments.length === 0) {
|
||||||
|
return { queued: null };
|
||||||
|
}
|
||||||
|
const item: QueuedComposerMessage = {
|
||||||
|
id: generateMessageId(),
|
||||||
|
text: trimmed,
|
||||||
|
attachments: input.attachments,
|
||||||
|
};
|
||||||
|
input.queue.write((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(input.agentId, [...(prev.get(input.agentId) ?? []), item]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return { queued: item };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditQueuedComposerMessageInput {
|
||||||
|
agentId: string;
|
||||||
|
messageId: string;
|
||||||
|
queue: QueueWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditQueuedComposerMessageResult {
|
||||||
|
text: string;
|
||||||
|
attachments: UserComposerAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editQueuedComposerMessage(
|
||||||
|
input: EditQueuedComposerMessageInput,
|
||||||
|
): EditQueuedComposerMessageResult | null {
|
||||||
|
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||||
|
if (!item) return null;
|
||||||
|
input.queue.write((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(
|
||||||
|
input.agentId,
|
||||||
|
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
text: item.text,
|
||||||
|
attachments: userAttachmentsOnly(item.attachments),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendQueuedComposerMessageNowInput {
|
||||||
|
agentId: string;
|
||||||
|
messageId: string;
|
||||||
|
queue: QueueWriter;
|
||||||
|
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SendQueuedComposerMessageNowResult =
|
||||||
|
| { status: "missing" }
|
||||||
|
| { status: "submitted" }
|
||||||
|
| { status: "failed"; errorMessage: string };
|
||||||
|
|
||||||
|
export async function sendQueuedComposerMessageNow(
|
||||||
|
input: SendQueuedComposerMessageNowInput,
|
||||||
|
): Promise<SendQueuedComposerMessageNowResult> {
|
||||||
|
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||||
|
if (!item) return { status: "missing" };
|
||||||
|
input.queue.write((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(
|
||||||
|
input.agentId,
|
||||||
|
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await input.submitMessage({ text: item.text, attachments: item.attachments });
|
||||||
|
return { status: "submitted" };
|
||||||
|
} catch (error) {
|
||||||
|
input.queue.write((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(input.agentId, [item, ...(prev.get(input.agentId) ?? [])]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
errorMessage: error instanceof Error ? error.message : "Failed to send message",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenComposerAttachmentInput {
|
||||||
|
attachment: ComposerAttachment;
|
||||||
|
setLightboxMetadata: (metadata: AttachmentMetadata) => void;
|
||||||
|
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean;
|
||||||
|
openExternalUrl: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openComposerAttachment(input: OpenComposerAttachmentInput): void {
|
||||||
|
if (input.attachment.kind === "image") {
|
||||||
|
input.setLightboxMetadata(input.attachment.metadata);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isWorkspaceAttachment(input.attachment)) {
|
||||||
|
input.openWorkspaceAttachment({ attachment: input.attachment });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.openExternalUrl(input.attachment.item.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||||
|
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleGithubAttachment(
|
||||||
|
current: UserComposerAttachment[],
|
||||||
|
item: GitHubSearchItem,
|
||||||
|
): UserComposerAttachment[] {
|
||||||
|
const matches = (attachment: UserComposerAttachment) =>
|
||||||
|
attachment.kind !== "image" &&
|
||||||
|
attachment.item.kind === item.kind &&
|
||||||
|
attachment.item.number === item.number;
|
||||||
|
if (current.some(matches)) {
|
||||||
|
return current.filter((attachment) => !matches(attachment));
|
||||||
|
}
|
||||||
|
return [...current, buildGithubAttachment(item)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findGithubItemByOption(
|
||||||
|
items: readonly GitHubSearchItem[],
|
||||||
|
optionId: string,
|
||||||
|
): GitHubSearchItem | undefined {
|
||||||
|
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAttachmentSelectedForGithubItem(
|
||||||
|
current: readonly ComposerAttachment[],
|
||||||
|
item: GitHubSearchItem,
|
||||||
|
): boolean {
|
||||||
|
return userAttachmentsOnly(current).some(
|
||||||
|
(attachment) =>
|
||||||
|
attachment.kind !== "image" &&
|
||||||
|
attachment.item.kind === item.kind &&
|
||||||
|
attachment.item.number === item.number,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
import {
|
||||||
|
isWorkspaceAttachment,
|
||||||
|
workspaceAttachmentToSubmitAttachment,
|
||||||
|
} from "@/attachments/workspace-attachment-utils";
|
||||||
import type { AgentAttachment } from "@server/shared/messages";
|
import type { AgentAttachment } from "@server/shared/messages";
|
||||||
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
||||||
|
|
||||||
@@ -18,8 +21,8 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (composerWorkspaceAttachment.is(attachment)) {
|
if (isWorkspaceAttachment(attachment)) {
|
||||||
const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment);
|
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
|
||||||
if (workspaceAttachment) {
|
if (workspaceAttachment) {
|
||||||
reviewAttachments.push(workspaceAttachment);
|
reviewAttachments.push(workspaceAttachment);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,6 @@ import {
|
|||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
|
||||||
import {
|
import {
|
||||||
AgentStatusBar,
|
AgentStatusBar,
|
||||||
DraftAgentStatusBar,
|
DraftAgentStatusBar,
|
||||||
@@ -31,7 +30,6 @@ import {
|
|||||||
} from "./agent-status-bar";
|
} from "./agent-status-bar";
|
||||||
import { ContextWindowMeter } from "./context-window-meter";
|
import { ContextWindowMeter } from "./context-window-meter";
|
||||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
MessageInput,
|
MessageInput,
|
||||||
@@ -44,6 +42,22 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|||||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
|
import {
|
||||||
|
cancelComposerAgent,
|
||||||
|
dispatchComposerAgentMessage,
|
||||||
|
editQueuedComposerMessage,
|
||||||
|
findGithubItemByOption,
|
||||||
|
isAttachmentSelectedForGithubItem,
|
||||||
|
openComposerAttachment,
|
||||||
|
pickAndPersistImages,
|
||||||
|
queueComposerMessage,
|
||||||
|
removeComposerAttachmentAtIndex,
|
||||||
|
sendQueuedComposerMessageNow,
|
||||||
|
toggleGithubAttachment,
|
||||||
|
type AgentStreamWriter,
|
||||||
|
type QueueWriter,
|
||||||
|
type QueuedComposerMessage,
|
||||||
|
} from "@/components/composer-actions";
|
||||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
@@ -79,17 +93,12 @@ import type {
|
|||||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
|
||||||
import { AttachmentPill } from "@/components/attachment-pill";
|
import { AttachmentPill } from "@/components/attachment-pill";
|
||||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||||
|
|
||||||
interface QueuedMessage {
|
type QueuedMessage = QueuedComposerMessage;
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
attachments: ComposerAttachment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type AttachmentListUpdater =
|
type AttachmentListUpdater =
|
||||||
| UserComposerAttachment[]
|
| UserComposerAttachment[]
|
||||||
@@ -128,37 +137,6 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
|
|||||||
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickAndPersistImages(
|
|
||||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>,
|
|
||||||
): Promise<ImageAttachment[]> {
|
|
||||||
const result = await pickImages();
|
|
||||||
if (!result?.length) return [];
|
|
||||||
return await Promise.all(
|
|
||||||
result.map(async (pickedImage) => {
|
|
||||||
if (pickedImage.source.kind === "blob") {
|
|
||||||
return await persistAttachmentFromBlob({
|
|
||||||
blob: pickedImage.source.blob,
|
|
||||||
mimeType: pickedImage.mimeType || "image/jpeg",
|
|
||||||
fileName: pickedImage.fileName ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return await persistAttachmentFromFileUri({
|
|
||||||
uri: pickedImage.source.uri,
|
|
||||||
mimeType: pickedImage.mimeType || "image/jpeg",
|
|
||||||
fileName: pickedImage.fileName ?? null,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index: number): T[] {
|
|
||||||
const removed = prev[index];
|
|
||||||
if (removed?.kind === "image") {
|
|
||||||
void deleteAttachments([removed.metadata]);
|
|
||||||
}
|
|
||||||
return prev.filter((_, i) => i !== index);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
||||||
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
|
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
|
||||||
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
|
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
|
||||||
@@ -237,45 +215,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
|||||||
return <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
|
return <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findGithubItemByOption(
|
|
||||||
items: readonly GitHubSearchItem[],
|
|
||||||
optionId: string,
|
|
||||||
): GitHubSearchItem | undefined {
|
|
||||||
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAttachmentSelectedForGithubItem(
|
|
||||||
current: readonly ComposerAttachment[],
|
|
||||||
item: GitHubSearchItem,
|
|
||||||
): boolean {
|
|
||||||
return composerWorkspaceAttachment
|
|
||||||
.userAttachmentsOnly(current)
|
|
||||||
.some(
|
|
||||||
(attachment) =>
|
|
||||||
attachment.kind !== "image" &&
|
|
||||||
attachment.item.kind === item.kind &&
|
|
||||||
attachment.item.number === item.number,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
|
||||||
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleGithubAttachment(
|
|
||||||
current: UserComposerAttachment[],
|
|
||||||
item: GitHubSearchItem,
|
|
||||||
): UserComposerAttachment[] {
|
|
||||||
const matches = (attachment: UserComposerAttachment) =>
|
|
||||||
attachment.kind !== "image" &&
|
|
||||||
attachment.item.kind === item.kind &&
|
|
||||||
attachment.item.number === item.number;
|
|
||||||
if (current.some(matches)) {
|
|
||||||
return current.filter((attachment) => !matches(attachment));
|
|
||||||
}
|
|
||||||
return [...current, buildGithubAttachment(item)];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RenderAttachmentPreviewListArgs {
|
interface RenderAttachmentPreviewListArgs {
|
||||||
selectedAttachments: ComposerAttachment[];
|
selectedAttachments: ComposerAttachment[];
|
||||||
isComposerLocked: boolean;
|
isComposerLocked: boolean;
|
||||||
@@ -416,104 +355,6 @@ function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DispatchAgentMessageSendArgs {
|
|
||||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
|
||||||
serverId: string;
|
|
||||||
targetAgentId: string;
|
|
||||||
text: string;
|
|
||||||
sendAttachments: ComposerAttachment[];
|
|
||||||
setAgentStreamHead: ReturnType<typeof useSessionStore.getState>["setAgentStreamHead"];
|
|
||||||
setAgentStreamTail: ReturnType<typeof useSessionStore.getState>["setAgentStreamTail"];
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendUserMessageToStream(
|
|
||||||
args: DispatchAgentMessageSendArgs & { userMessage: StreamItem },
|
|
||||||
): void {
|
|
||||||
const { serverId, targetAgentId, userMessage, setAgentStreamHead, setAgentStreamTail } = args;
|
|
||||||
const currentHead = useSessionStore
|
|
||||||
.getState()
|
|
||||||
.sessions[serverId]?.agentStreamHead?.get(targetAgentId);
|
|
||||||
if (currentHead && currentHead.length > 0) {
|
|
||||||
setAgentStreamHead(serverId, (prev) => {
|
|
||||||
const head = prev.get(targetAgentId) || [];
|
|
||||||
const updated = new Map(prev);
|
|
||||||
updated.set(targetAgentId, [...head, userMessage]);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAgentStreamTail(serverId, (prev) => {
|
|
||||||
const currentStream = prev.get(targetAgentId) || [];
|
|
||||||
const updated = new Map(prev);
|
|
||||||
updated.set(targetAgentId, [...currentStream, userMessage]);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Promise<void> {
|
|
||||||
const { client, targetAgentId, text, sendAttachments } = args;
|
|
||||||
const wirePayload = splitComposerAttachmentsForSubmit(sendAttachments);
|
|
||||||
const clientMessageId = generateMessageId();
|
|
||||||
const userMessage: StreamItem = {
|
|
||||||
kind: "user_message",
|
|
||||||
id: clientMessageId,
|
|
||||||
text,
|
|
||||||
timestamp: new Date(),
|
|
||||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
|
||||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
|
||||||
};
|
|
||||||
appendUserMessageToStream({ ...args, userMessage });
|
|
||||||
const imagesData = await encodeImages(wirePayload.images);
|
|
||||||
await client.sendAgentMessage(targetAgentId, text, {
|
|
||||||
messageId: clientMessageId,
|
|
||||||
images: imagesData ?? [],
|
|
||||||
attachments: wirePayload.attachments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openComposerAttachment(
|
|
||||||
attachment: ComposerAttachment,
|
|
||||||
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
|
|
||||||
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean,
|
|
||||||
): void {
|
|
||||||
if (attachment.kind === "image") {
|
|
||||||
setLightboxMetadata(attachment.metadata);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (composerWorkspaceAttachment.is(attachment)) {
|
|
||||||
openWorkspaceAttachment({ attachment });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void openExternalUrl(attachment.item.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CancelRunningAgentArgs {
|
|
||||||
isAgentRunning: boolean;
|
|
||||||
isCancellingAgent: boolean;
|
|
||||||
isConnected: boolean;
|
|
||||||
client: ReturnType<typeof useHostRuntimeClient>;
|
|
||||||
agentIdRef: { current: string };
|
|
||||||
setIsCancellingAgent: (value: boolean) => void;
|
|
||||||
messageInputRef: { current: MessageInputRef | null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelRunningAgent(args: CancelRunningAgentArgs): void {
|
|
||||||
const {
|
|
||||||
isAgentRunning,
|
|
||||||
isCancellingAgent,
|
|
||||||
isConnected,
|
|
||||||
client,
|
|
||||||
agentIdRef,
|
|
||||||
setIsCancellingAgent,
|
|
||||||
messageInputRef,
|
|
||||||
} = args;
|
|
||||||
if (!isAgentRunning || isCancellingAgent) return;
|
|
||||||
if (!isConnected || !client) return;
|
|
||||||
setIsCancellingAgent(true);
|
|
||||||
void client.cancelAgent(agentIdRef.current);
|
|
||||||
messageInputRef.current?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusMessageInputWithPlatformStrategy(messageInputRef: {
|
function focusMessageInputWithPlatformStrategy(messageInputRef: {
|
||||||
current: MessageInputRef | null;
|
current: MessageInputRef | null;
|
||||||
}): void {
|
}): void {
|
||||||
@@ -1152,14 +993,18 @@ export function Composer({
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
await dispatchAgentMessageSend({
|
const stream: AgentStreamWriter = {
|
||||||
|
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
|
||||||
|
setHead: (updater) => setAgentStreamHead(serverId, updater),
|
||||||
|
setTail: (updater) => setAgentStreamTail(serverId, updater),
|
||||||
|
};
|
||||||
|
await dispatchComposerAgentMessage({
|
||||||
client,
|
client,
|
||||||
serverId,
|
agentId: targetAgentId,
|
||||||
targetAgentId,
|
|
||||||
text,
|
text,
|
||||||
sendAttachments,
|
attachments: sendAttachments,
|
||||||
setAgentStreamHead,
|
encodeImages,
|
||||||
setAgentStreamTail,
|
stream,
|
||||||
});
|
});
|
||||||
onAttentionPromptSend?.();
|
onAttentionPromptSend?.();
|
||||||
};
|
};
|
||||||
@@ -1172,33 +1017,23 @@ export function Composer({
|
|||||||
const isAgentRunning = agentState.status === "running";
|
const isAgentRunning = agentState.status === "running";
|
||||||
const hasAgent = agentState.status !== null;
|
const hasAgent = agentState.status !== null;
|
||||||
|
|
||||||
const updateQueue = useCallback(
|
const queueWriter = useMemo<QueueWriter>(
|
||||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
() => ({
|
||||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
|
||||||
const next = new Map(prev);
|
write: (updater) => setQueuedMessages(serverId, updater),
|
||||||
next.set(agentId, updater(prev.get(agentId) ?? []));
|
}),
|
||||||
return next;
|
[serverId, setQueuedMessages],
|
||||||
});
|
|
||||||
},
|
|
||||||
[agentId, serverId, setQueuedMessages],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const queueMessage = useCallback(
|
const queueMessage = useCallback(
|
||||||
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
|
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
|
||||||
const trimmedMessage = queuedMessage.trim();
|
const result = queueComposerMessage({
|
||||||
if (!trimmedMessage && queuedAttachments.length === 0) return;
|
agentId,
|
||||||
|
text: queuedMessage,
|
||||||
const newItem = {
|
|
||||||
id: generateMessageId(),
|
|
||||||
text: trimmedMessage,
|
|
||||||
attachments: queuedAttachments,
|
attachments: queuedAttachments,
|
||||||
};
|
queue: queueWriter,
|
||||||
|
|
||||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
|
if (!result.queued) return;
|
||||||
|
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
setSelectedAttachments([]);
|
setSelectedAttachments([]);
|
||||||
@@ -1208,9 +1043,8 @@ export function Composer({
|
|||||||
[
|
[
|
||||||
agentId,
|
agentId,
|
||||||
clearSentAttachments,
|
clearSentAttachments,
|
||||||
|
queueWriter,
|
||||||
resetSuppression,
|
resetSuppression,
|
||||||
serverId,
|
|
||||||
setQueuedMessages,
|
|
||||||
setSelectedAttachments,
|
setSelectedAttachments,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
],
|
],
|
||||||
@@ -1284,7 +1118,15 @@ export function Composer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePickImage = useCallback(async () => {
|
const handlePickImage = useCallback(async () => {
|
||||||
const newImages = await pickAndPersistImages(pickImages);
|
const newImages = await pickAndPersistImages({
|
||||||
|
pickImages,
|
||||||
|
persister: {
|
||||||
|
persistFromBlob: ({ blob, mimeType, fileName }) =>
|
||||||
|
persistAttachmentFromBlob({ blob, mimeType, fileName }),
|
||||||
|
persistFromFileUri: ({ uri, mimeType, fileName }) =>
|
||||||
|
persistAttachmentFromFileUri({ uri, mimeType, fileName }),
|
||||||
|
},
|
||||||
|
});
|
||||||
if (newImages.length === 0) return;
|
if (newImages.length === 0) return;
|
||||||
addImages(newImages);
|
addImages(newImages);
|
||||||
}, [addImages, pickImages]);
|
}, [addImages, pickImages]);
|
||||||
@@ -1298,14 +1140,23 @@ export function Composer({
|
|||||||
if (didRemoveWorkspaceAttachment) {
|
if (didRemoveWorkspaceAttachment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
|
setSelectedAttachments((prev) =>
|
||||||
|
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[removeAttachment, selectedAttachments, setSelectedAttachments],
|
[removeAttachment, selectedAttachments, setSelectedAttachments],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenAttachment = useCallback(
|
const handleOpenAttachment = useCallback(
|
||||||
(attachment: ComposerAttachment) => {
|
(attachment: ComposerAttachment) => {
|
||||||
openComposerAttachment(attachment, setLightboxMetadata, openAttachment);
|
openComposerAttachment({
|
||||||
|
attachment,
|
||||||
|
setLightboxMetadata,
|
||||||
|
openWorkspaceAttachment: openAttachment,
|
||||||
|
openExternalUrl: (url) => {
|
||||||
|
void openExternalUrl(url);
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[openAttachment],
|
[openAttachment],
|
||||||
);
|
);
|
||||||
@@ -1317,15 +1168,16 @@ export function Composer({
|
|||||||
}, [isAgentRunning, isConnected]);
|
}, [isAgentRunning, isConnected]);
|
||||||
|
|
||||||
const handleCancelAgent = useCallback(() => {
|
const handleCancelAgent = useCallback(() => {
|
||||||
cancelRunningAgent({
|
const didCancel = cancelComposerAgent({
|
||||||
|
client,
|
||||||
|
agentId: agentIdRef.current,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
isCancellingAgent,
|
isCancellingAgent,
|
||||||
isConnected,
|
isConnected,
|
||||||
client,
|
|
||||||
agentIdRef,
|
|
||||||
setIsCancellingAgent,
|
|
||||||
messageInputRef,
|
|
||||||
});
|
});
|
||||||
|
if (!didCancel) return;
|
||||||
|
setIsCancellingAgent(true);
|
||||||
|
messageInputRef.current?.focus();
|
||||||
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
|
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
|
||||||
|
|
||||||
const focusMessageInputForKeyboardAction = useCallback(() => {
|
const focusMessageInputForKeyboardAction = useCallback(() => {
|
||||||
@@ -1391,33 +1243,34 @@ export function Composer({
|
|||||||
|
|
||||||
const handleEditQueuedMessage = useCallback(
|
const handleEditQueuedMessage = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
const result = editQueuedComposerMessage({
|
||||||
if (!item) return;
|
agentId,
|
||||||
|
messageId: id,
|
||||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
queue: queueWriter,
|
||||||
setUserInput(item.text);
|
});
|
||||||
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments));
|
if (!result) return;
|
||||||
|
setUserInput(result.text);
|
||||||
|
setSelectedAttachments(result.attachments);
|
||||||
},
|
},
|
||||||
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
|
[agentId, queueWriter, setSelectedAttachments, setUserInput],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSendQueuedNow = useCallback(
|
const handleSendQueuedNow = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
|
||||||
if (!item) return;
|
|
||||||
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
||||||
|
|
||||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
|
||||||
|
|
||||||
// Reuse the regular send path; server-side send atomically interrupts any active run.
|
// Reuse the regular send path; server-side send atomically interrupts any active run.
|
||||||
try {
|
const result = await sendQueuedComposerMessageNow({
|
||||||
await submitMessage(item.text, item.attachments);
|
agentId,
|
||||||
} catch (error) {
|
messageId: id,
|
||||||
updateQueue((current) => [item, ...current]);
|
queue: queueWriter,
|
||||||
setSendError(error instanceof Error ? error.message : "Failed to send message");
|
submitMessage: ({ text, attachments: queuedAttachments }) =>
|
||||||
|
submitMessage(text, queuedAttachments),
|
||||||
|
});
|
||||||
|
if (result.status === "failed") {
|
||||||
|
setSendError(result.errorMessage);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[queuedMessages, submitMessage, updateQueue],
|
[agentId, queueWriter, submitMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleQueue = useCallback(
|
const handleQueue = useCallback(
|
||||||
|
|||||||
@@ -1,249 +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";
|
|
||||||
|
|
||||||
const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => {
|
|
||||||
const hoistedPanelState = {
|
|
||||||
isOpen: false,
|
|
||||||
showMobileAgent: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
panelState: hoistedPanelState,
|
|
||||||
useSidebarWorkspacesListMock: vi.fn(),
|
|
||||||
theme: {
|
|
||||||
spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 },
|
|
||||||
iconSize: { sm: 14, md: 18, lg: 22 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
|
|
||||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
|
||||||
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
|
||||||
colors: {
|
|
||||||
surfaceSidebar: "#111",
|
|
||||||
surface1: "#111",
|
|
||||||
surface2: "#222",
|
|
||||||
surface3: "#333",
|
|
||||||
surface4: "#444",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
border: "#555",
|
|
||||||
borderAccent: "#666",
|
|
||||||
accent: "#0a84ff",
|
|
||||||
accentForeground: "#fff",
|
|
||||||
palette: {
|
|
||||||
green: { 400: "#30d158" },
|
|
||||||
amber: { 500: "#ffd60a" },
|
|
||||||
red: { 500: "#ff453a" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
absoluteFillObject: {},
|
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-reanimated", () => ({
|
|
||||||
default: {
|
|
||||||
View: "div",
|
|
||||||
},
|
|
||||||
Extrapolation: { CLAMP: "clamp" },
|
|
||||||
interpolate: () => 0,
|
|
||||||
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
|
|
||||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
|
||||||
useSharedValue: (value: unknown) => ({ value }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-gesture-handler", () => {
|
|
||||||
const chain = {
|
|
||||||
enabled: () => chain,
|
|
||||||
hitSlop: () => chain,
|
|
||||||
manualActivation: () => chain,
|
|
||||||
onTouchesDown: () => chain,
|
|
||||||
onTouchesMove: () => chain,
|
|
||||||
onStart: () => chain,
|
|
||||||
onUpdate: () => chain,
|
|
||||||
onEnd: () => chain,
|
|
||||||
onFinalize: () => chain,
|
|
||||||
withRef: () => chain,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
Gesture: { Pan: () => chain },
|
|
||||||
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
|
||||||
React.createElement("span", { ...props, "data-icon": name });
|
|
||||||
return {
|
|
||||||
FolderPlus: createIcon("FolderPlus"),
|
|
||||||
MessagesSquare: createIcon("MessagesSquare"),
|
|
||||||
Plus: createIcon("Plus"),
|
|
||||||
Settings: createIcon("Settings"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("expo-router", () => ({
|
|
||||||
router: { push: vi.fn() },
|
|
||||||
usePathname: () => "/hosts/srv",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-safe-area-context", () => ({
|
|
||||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/layout", () => ({
|
|
||||||
useIsCompactFormFactor: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
|
||||||
isWeb: true,
|
|
||||||
isNative: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/panel-store", () => ({
|
|
||||||
MIN_SIDEBAR_WIDTH: 260,
|
|
||||||
MAX_SIDEBAR_WIDTH: 420,
|
|
||||||
selectIsAgentListOpen: (state: typeof panelState) => state.isOpen,
|
|
||||||
usePanelStore: (selector: (state: typeof panelState) => unknown) => selector(panelState),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHosts: () => [{ serverId: "srv", label: "Local" }],
|
|
||||||
useHostRuntimeSnapshot: () => ({ connectionStatus: "online" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-sidebar-workspaces-list", () => ({
|
|
||||||
useSidebarWorkspacesList: useSidebarWorkspacesListMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-sidebar-shortcut-model", () => ({
|
|
||||||
useSidebarShortcutModel: () => ({
|
|
||||||
collapsedProjectKeys: new Set<string>(),
|
|
||||||
shortcutIndexByWorkspaceKey: new Map<string, number>(),
|
|
||||||
toggleProjectCollapsed: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/contexts/sidebar-animation-context", () => ({
|
|
||||||
useSidebarAnimation: () => ({
|
|
||||||
translateX: { value: 0 },
|
|
||||||
backdropOpacity: { value: 0 },
|
|
||||||
windowWidth: 390,
|
|
||||||
animateToOpen: vi.fn(),
|
|
||||||
animateToClose: vi.fn(),
|
|
||||||
isGesturing: { value: false },
|
|
||||||
gestureAnimatingRef: { current: false },
|
|
||||||
closeGestureRef: { current: undefined },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
|
||||||
useShortcutKeys: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/desktop-window", () => ({
|
|
||||||
useWindowControlsPadding: () => ({ top: 0 }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/host-routes", () => ({
|
|
||||||
buildHostSessionsRoute: (serverId: string) => `/hosts/${serverId}/sessions`,
|
|
||||||
buildSettingsRoute: () => "/settings",
|
|
||||||
mapPathnameToServer: (_pathname: string, serverId: string) => `/hosts/${serverId}`,
|
|
||||||
parseServerIdFromPathname: () => "srv",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-open-project-picker", () => ({
|
|
||||||
useOpenProjectPicker: () => vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/sidebar/sidebar-header-row", () => ({
|
|
||||||
SidebarHeaderRow: ({ label }: { label: string }) => React.createElement("div", null, label),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./sidebar-workspace-list", () => ({
|
|
||||||
SidebarWorkspaceList: ({ projects }: { projects: Array<{ projectName: string }> }) =>
|
|
||||||
React.createElement(
|
|
||||||
"div",
|
|
||||||
{ "data-testid": "sidebar-workspace-list" },
|
|
||||||
projects.map((project) => project.projectName).join(","),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./sidebar-agent-list-skeleton", () => ({
|
|
||||||
SidebarAgentListSkeleton: () => React.createElement("div", null, "Loading"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/tooltip", () => ({
|
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
|
||||||
React.createElement("div", null, children),
|
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
|
||||||
React.createElement("div", null, children),
|
|
||||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
|
||||||
React.createElement("div", null, children),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/shortcut", () => ({
|
|
||||||
Shortcut: () => React.createElement("span", null),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/combobox", () => ({
|
|
||||||
Combobox: () => null,
|
|
||||||
ComboboxItem: ({ label }: { label: string }) => React.createElement("div", null, label),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
|
|
||||||
import { LeftSidebar } from "./left-sidebar";
|
|
||||||
|
|
||||||
describe("LeftSidebar", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
panelState.isOpen = false;
|
|
||||||
panelState.showMobileAgent.mockReset();
|
|
||||||
useSidebarWorkspacesListMock.mockReset();
|
|
||||||
useSidebarWorkspacesListMock.mockReturnValue({
|
|
||||||
projects: [{ projectKey: "project-1", projectName: "Project 1", workspaces: [] }],
|
|
||||||
isInitialLoad: false,
|
|
||||||
isRevalidating: false,
|
|
||||||
refreshAll: vi.fn(),
|
|
||||||
});
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the mobile workspace list subscribed while the sidebar is hidden", async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<LeftSidebar />);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({
|
|
||||||
serverId: "srv",
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { computeCanStartDictation } from "./message-input-state";
|
||||||
|
|
||||||
|
const connected = { isConnected: true } as never;
|
||||||
|
const disconnected = { isConnected: false } as never;
|
||||||
|
|
||||||
|
describe("computeCanStartDictation", () => {
|
||||||
|
it("returns false when socket is disconnected", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: disconnected,
|
||||||
|
isReadyForDictation: true,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when isReadyForDictation is explicitly false", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: connected,
|
||||||
|
isReadyForDictation: false,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when connected and ready", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: connected,
|
||||||
|
isReadyForDictation: true,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to socket connected state when isReadyForDictation is undefined", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: connected,
|
||||||
|
isReadyForDictation: undefined,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: disconnected,
|
||||||
|
isReadyForDictation: undefined,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when the input is disabled", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: connected,
|
||||||
|
isReadyForDictation: true,
|
||||||
|
disabled: true,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when a dictation unavailable message is present", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: connected,
|
||||||
|
isReadyForDictation: true,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: "Microphone unavailable",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when client is null", () => {
|
||||||
|
expect(
|
||||||
|
computeCanStartDictation({
|
||||||
|
client: null,
|
||||||
|
isReadyForDictation: true,
|
||||||
|
disabled: false,
|
||||||
|
dictationUnavailableMessage: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
|
||||||
|
export function computeCanStartDictation(input: {
|
||||||
|
client: DaemonClient | null;
|
||||||
|
isReadyForDictation: boolean | undefined;
|
||||||
|
disabled: boolean;
|
||||||
|
dictationUnavailableMessage: string | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
const socketConnected = input.client?.isConnected ?? false;
|
||||||
|
const readyForDictation = input.isReadyForDictation ?? socketConnected;
|
||||||
|
return (
|
||||||
|
socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
import React, { createRef } from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input";
|
|
||||||
|
|
||||||
const EMPTY_ATTACHMENTS: React.ComponentProps<typeof MessageInput>["attachments"] = [];
|
|
||||||
const EMPTY_ATTACHMENT_MENU_ITEMS: AttachmentMenuItem[] = [];
|
|
||||||
const FAKE_CONNECTED_CLIENT = { isConnected: true } as never;
|
|
||||||
|
|
||||||
const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({
|
|
||||||
startDictationMock: vi.fn(),
|
|
||||||
cancelDictationMock: vi.fn(),
|
|
||||||
confirmDictationMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
|
||||||
theme: {
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
iconSize: { sm: 14, md: 18, lg: 22 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
|
|
||||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
|
||||||
fontWeight: { normal: "400" },
|
|
||||||
shadow: { md: {} },
|
|
||||||
colors: {
|
|
||||||
surface1: "#111",
|
|
||||||
surface2: "#222",
|
|
||||||
surface3: "#333",
|
|
||||||
surface4: "#888",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
popoverForeground: "#fff",
|
|
||||||
borderAccent: "#444",
|
|
||||||
accent: "#0a84ff",
|
|
||||||
accentForeground: "#fff",
|
|
||||||
destructive: "#ff453a",
|
|
||||||
palette: {
|
|
||||||
green: { 500: "#30d158" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
||||||
},
|
|
||||||
withUnistyles: <T,>(component: T) => component,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
|
||||||
isWeb: false,
|
|
||||||
isNative: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
|
||||||
React.createElement("span", { ...props, "data-icon": name });
|
|
||||||
return {
|
|
||||||
ArrowUp: createIcon("ArrowUp"),
|
|
||||||
Mic: createIcon("Mic"),
|
|
||||||
MicOff: createIcon("MicOff"),
|
|
||||||
CornerDownLeft: createIcon("CornerDownLeft"),
|
|
||||||
Plus: createIcon("Plus"),
|
|
||||||
Square: createIcon("Square"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("react-native-reanimated", () => ({
|
|
||||||
default: {
|
|
||||||
View: "div",
|
|
||||||
},
|
|
||||||
Keyframe: class Keyframe {
|
|
||||||
duration() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
withCallback() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
|
|
||||||
useSharedValue: (value: unknown) => ({ value }),
|
|
||||||
useAnimatedStyle: (factory: () => unknown) => factory(),
|
|
||||||
withTiming: (value: unknown) => value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-dictation", () => ({
|
|
||||||
useDictation: () => ({
|
|
||||||
isRecording: false,
|
|
||||||
isProcessing: false,
|
|
||||||
partialTranscript: "",
|
|
||||||
volume: 0,
|
|
||||||
duration: 0,
|
|
||||||
error: null,
|
|
||||||
status: "idle",
|
|
||||||
startDictation: startDictationMock,
|
|
||||||
cancelDictation: cancelDictationMock,
|
|
||||||
confirmDictation: confirmDictationMock,
|
|
||||||
retryFailedDictation: vi.fn(),
|
|
||||||
discardFailedDictation: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/session-store", () => ({
|
|
||||||
useSessionStore: (selector: (state: { sessions: Record<string, unknown> }) => unknown) =>
|
|
||||||
selector({ sessions: {} }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/contexts/voice-context", () => ({
|
|
||||||
useVoiceOptional: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/contexts/toast-context", () => ({
|
|
||||||
useToast: () => ({ error: vi.fn() }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/server-info-capabilities", () => ({
|
|
||||||
resolveVoiceUnavailableMessage: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/use-web-scrollbar", () => ({
|
|
||||||
useWebElementScrollbar: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock(
|
|
||||||
"@/hooks/use-web-scrollbar-style",
|
|
||||||
() => ({
|
|
||||||
useWebScrollbarStyle: () => undefined,
|
|
||||||
}),
|
|
||||||
// @ts-expect-error Vitest accepts virtual mocks at runtime; the app's types omit this overload.
|
|
||||||
{ virtual: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
|
||||||
useShortcutKeys: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/shortcut", () => ({
|
|
||||||
Shortcut: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/tooltip", () => ({
|
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
TooltipTrigger: ({
|
|
||||||
asChild,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
asChild?: boolean;
|
|
||||||
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
|
||||||
} & Record<string, unknown>) =>
|
|
||||||
asChild ? (
|
|
||||||
children
|
|
||||||
) : (
|
|
||||||
<button type="button" aria-label={props.accessibilityLabel as string | undefined}>
|
|
||||||
{typeof children === "function" ? children({ hovered: false }) : children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
DropdownMenuTrigger: ({
|
|
||||||
children,
|
|
||||||
testID,
|
|
||||||
accessibilityLabel,
|
|
||||||
}: {
|
|
||||||
children:
|
|
||||||
| React.ReactNode
|
|
||||||
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
|
|
||||||
testID?: string;
|
|
||||||
accessibilityLabel?: string;
|
|
||||||
}) => (
|
|
||||||
<button type="button" data-testid={testID} aria-label={accessibilityLabel}>
|
|
||||||
{typeof children === "function"
|
|
||||||
? children({ hovered: false, pressed: false, open: false })
|
|
||||||
: children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
|
||||||
<div data-testid={testID}>{children}</div>
|
|
||||||
),
|
|
||||||
DropdownMenuItem: ({
|
|
||||||
children,
|
|
||||||
onSelect,
|
|
||||||
testID,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
onSelect?: () => void;
|
|
||||||
testID?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) => (
|
|
||||||
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./dictation-controls", () => ({
|
|
||||||
DictationOverlay: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./realtime-voice-overlay", () => ({
|
|
||||||
RealtimeVoiceOverlay: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
vi.stubGlobal("window", dom.window);
|
|
||||||
vi.stubGlobal("document", dom.window.document);
|
|
||||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
|
||||||
vi.stubGlobal("Node", dom.window.Node);
|
|
||||||
vi.stubGlobal("navigator", dom.window.navigator);
|
|
||||||
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
startDictationMock.mockReset();
|
|
||||||
cancelDictationMock.mockReset();
|
|
||||||
confirmDictationMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
interface RenderMessageInputOptions {
|
|
||||||
value?: string;
|
|
||||||
submitIcon?: "arrow" | "return";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMessageInput(
|
|
||||||
menuItems: AttachmentMenuItem[],
|
|
||||||
{ value = "", submitIcon }: RenderMessageInputOptions = {},
|
|
||||||
) {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<MessageInput
|
|
||||||
value={value}
|
|
||||||
onChangeText={vi.fn()}
|
|
||||||
onSubmit={vi.fn()}
|
|
||||||
attachments={EMPTY_ATTACHMENTS}
|
|
||||||
cwd="/repo"
|
|
||||||
attachmentMenuItems={menuItems}
|
|
||||||
client={FAKE_CONNECTED_CLIENT}
|
|
||||||
isAgentRunning={false}
|
|
||||||
submitIcon={submitIcon}
|
|
||||||
onQueue={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function click(element: Element) {
|
|
||||||
act(() => {
|
|
||||||
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryByTestId(testID: string): HTMLElement | null {
|
|
||||||
return document.querySelector(`[data-testid="${testID}"]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryAllByAriaLabel(label: string): NodeListOf<HTMLElement> {
|
|
||||||
return document.querySelectorAll(`[aria-label="${label}"]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("MessageInput attachments", () => {
|
|
||||||
it("renders the Plus attachment button and opens a menu with two attachment items", () => {
|
|
||||||
const menuItems: AttachmentMenuItem[] = [
|
|
||||||
{ id: "image", label: "Add image", onSelect: vi.fn() },
|
|
||||||
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
|
|
||||||
];
|
|
||||||
|
|
||||||
renderMessageInput(menuItems);
|
|
||||||
|
|
||||||
expect(document.querySelectorAll('[data-icon="Plus"]')).toHaveLength(1);
|
|
||||||
|
|
||||||
const attachButton = queryByTestId("message-input-attach-button");
|
|
||||||
expect(attachButton).not.toBeNull();
|
|
||||||
click(attachButton!);
|
|
||||||
|
|
||||||
expect(queryByTestId("message-input-attachment-menu-item-image")).not.toBeNull();
|
|
||||||
expect(queryByTestId("message-input-attachment-menu-item-github")).not.toBeNull();
|
|
||||||
expect(
|
|
||||||
document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'),
|
|
||||||
).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selecting Attach image invokes the supplied image attachment action", () => {
|
|
||||||
const attachImage = vi.fn();
|
|
||||||
|
|
||||||
renderMessageInput([
|
|
||||||
{ id: "image", label: "Attach image", onSelect: attachImage },
|
|
||||||
{ id: "github", label: "Attach GitHub issue or PR", onSelect: vi.fn() },
|
|
||||||
]);
|
|
||||||
|
|
||||||
click(queryByTestId("message-input-attach-button")!);
|
|
||||||
click(queryByTestId("message-input-attachment-menu-item-image")!);
|
|
||||||
|
|
||||||
expect(attachImage).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render the old queue button", () => {
|
|
||||||
renderMessageInput([
|
|
||||||
{ id: "image", label: "Add image", onSelect: vi.fn() },
|
|
||||||
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(queryAllByAriaLabel("Queue message")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses ArrowUp by default and CornerDownLeft when return submit icon is requested", () => {
|
|
||||||
renderMessageInput([], { value: "Send this" });
|
|
||||||
|
|
||||||
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(1);
|
|
||||||
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(0);
|
|
||||||
|
|
||||||
renderMessageInput([], { value: "Create this", submitIcon: "return" });
|
|
||||||
|
|
||||||
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(0);
|
|
||||||
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("MessageInput dictation shortcuts", () => {
|
|
||||||
it("does not poison the dictation toggle when readiness is temporarily false", () => {
|
|
||||||
const inputRef = createRef<MessageInputRef>();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<MessageInput
|
|
||||||
ref={inputRef}
|
|
||||||
value=""
|
|
||||||
onChangeText={vi.fn()}
|
|
||||||
onSubmit={vi.fn()}
|
|
||||||
attachments={EMPTY_ATTACHMENTS}
|
|
||||||
cwd="/repo"
|
|
||||||
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
|
|
||||||
client={FAKE_CONNECTED_CLIENT}
|
|
||||||
isAgentRunning={false}
|
|
||||||
isReadyForDictation={false}
|
|
||||||
onQueue={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
inputRef.current?.runKeyboardAction("dictation-toggle");
|
|
||||||
});
|
|
||||||
expect(startDictationMock).not.toHaveBeenCalled();
|
|
||||||
expect(confirmDictationMock).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<MessageInput
|
|
||||||
ref={inputRef}
|
|
||||||
value=""
|
|
||||||
onChangeText={vi.fn()}
|
|
||||||
onSubmit={vi.fn()}
|
|
||||||
attachments={EMPTY_ATTACHMENTS}
|
|
||||||
cwd="/repo"
|
|
||||||
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
|
|
||||||
client={FAKE_CONNECTED_CLIENT}
|
|
||||||
isAgentRunning={false}
|
|
||||||
isReadyForDictation
|
|
||||||
onQueue={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
inputRef.current?.runKeyboardAction("dictation-toggle");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(startDictationMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(confirmDictationMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -53,6 +53,7 @@ import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
|||||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useComposerHeightMirror } from "./composer-height-mirror";
|
import { useComposerHeightMirror } from "./composer-height-mirror";
|
||||||
|
import { computeCanStartDictation } from "./message-input-state";
|
||||||
|
|
||||||
export type ImageAttachment = AttachmentMetadata;
|
export type ImageAttachment = AttachmentMetadata;
|
||||||
|
|
||||||
@@ -968,19 +969,6 @@ function computeSendableContent(input: SendableContentInput): SendableContentOut
|
|||||||
return { hasAttachments, hasRealContent, hasSendableContent, shouldShowSendButton };
|
return { hasAttachments, hasRealContent, hasSendableContent, shouldShowSendButton };
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeCanStartDictation(input: {
|
|
||||||
client: DaemonClient | null;
|
|
||||||
isReadyForDictation: boolean | undefined;
|
|
||||||
disabled: boolean;
|
|
||||||
dictationUnavailableMessage: string | null | undefined;
|
|
||||||
}): boolean {
|
|
||||||
const socketConnected = input.client?.isConnected ?? false;
|
|
||||||
const readyForDictation = input.isReadyForDictation ?? socketConnected;
|
|
||||||
return (
|
|
||||||
socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeIsDictationStartEnabled(
|
function computeIsDictationStartEnabled(
|
||||||
isReadyForDictation: boolean | undefined,
|
isReadyForDictation: boolean | undefined,
|
||||||
isConnected: boolean,
|
isConnected: boolean,
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { PrPaneData } from "@/utils/pr-pane-data";
|
|
||||||
import { PrPane } from "./pr-pane";
|
|
||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
|
||||||
theme: {
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
|
||||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
|
||||||
fontWeight: { normal: "400" },
|
|
||||||
colors: {
|
|
||||||
surfaceSidebar: "#0b0b0b",
|
|
||||||
surfaceSidebarHover: "#151515",
|
|
||||||
border: "#222",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
statusSuccess: "#30d158",
|
|
||||||
statusDanger: "#ff453a",
|
|
||||||
statusWarning: "#f2c945",
|
|
||||||
statusMerged: "#a371f7",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
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"),
|
|
||||||
CircleCheck: createIcon("CircleCheck"),
|
|
||||||
CircleDot: createIcon("CircleDot"),
|
|
||||||
CircleSlash: createIcon("CircleSlash"),
|
|
||||||
CircleX: createIcon("CircleX"),
|
|
||||||
ExternalLink: createIcon("ExternalLink"),
|
|
||||||
GitMerge: createIcon("GitMerge"),
|
|
||||||
GitPullRequest: createIcon("GitPullRequest"),
|
|
||||||
GitPullRequestClosed: createIcon("GitPullRequestClosed"),
|
|
||||||
GitPullRequestDraft: createIcon("GitPullRequestDraft"),
|
|
||||||
MessageSquare: createIcon("MessageSquare"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/utils/open-external-url", () => ({
|
|
||||||
openExternalUrl: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
vi.stubGlobal("window", dom.window);
|
|
||||||
vi.stubGlobal("document", dom.window.document);
|
|
||||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
|
||||||
vi.stubGlobal("Node", dom.window.Node);
|
|
||||||
vi.stubGlobal("navigator", dom.window.navigator);
|
|
||||||
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeData(overrides: Partial<PrPaneData> = {}): PrPaneData {
|
|
||||||
return {
|
|
||||||
number: 1284,
|
|
||||||
title: "fix(server,app): coalesce tool call stream events",
|
|
||||||
state: "open",
|
|
||||||
url: "https://github.com/getpaseo/paseo/pull/1284",
|
|
||||||
reviewDecision: "changes_requested",
|
|
||||||
awaitingReviewers: [],
|
|
||||||
checks: [
|
|
||||||
{ name: "typecheck", workflow: "CI", status: "success", duration: "1m", url: "#" },
|
|
||||||
{ name: "test", workflow: "CI", status: "failure", duration: "2m", url: "#" },
|
|
||||||
],
|
|
||||||
activity: [
|
|
||||||
{
|
|
||||||
kind: "review",
|
|
||||||
author: "alicek",
|
|
||||||
avatarColor: "#8b5cf6",
|
|
||||||
reviewState: "approved",
|
|
||||||
body: "LGTM",
|
|
||||||
age: "2h ago",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "comment",
|
|
||||||
author: "bmartin",
|
|
||||||
avatarColor: "#f97316",
|
|
||||||
body: "Worth a benchmark before/after.",
|
|
||||||
age: "3h ago",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(data: PrPaneData) {
|
|
||||||
act(() => {
|
|
||||||
root?.render(<PrPane data={data} />);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("PrPane", () => {
|
|
||||||
it("renders the PR title and 'Open' state label from the data prop", () => {
|
|
||||||
render(makeData());
|
|
||||||
|
|
||||||
expect(container?.textContent).toContain("fix(server,app): coalesce tool call stream events");
|
|
||||||
expect(container?.textContent).toContain("Open");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the correct state label and icon for each PR state", () => {
|
|
||||||
render(makeData({ state: "draft" }));
|
|
||||||
expect(container?.textContent).toContain("Draft");
|
|
||||||
expect(container?.querySelector('[data-icon="GitPullRequestDraft"]')).not.toBeNull();
|
|
||||||
|
|
||||||
render(makeData({ state: "merged" }));
|
|
||||||
expect(container?.textContent).toContain("Merged");
|
|
||||||
expect(container?.querySelector('[data-icon="GitMerge"]')).not.toBeNull();
|
|
||||||
|
|
||||||
render(makeData({ state: "closed" }));
|
|
||||||
expect(container?.textContent).toContain("Closed");
|
|
||||||
expect(container?.querySelector('[data-icon="GitPullRequestClosed"]')).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives check summary counts from the data prop", () => {
|
|
||||||
render(makeData());
|
|
||||||
const text = container?.textContent ?? "";
|
|
||||||
|
|
||||||
expect(text).toContain("typecheck");
|
|
||||||
expect(text).toContain("test");
|
|
||||||
// 1 passed, 1 failed, 0 pending — so pending pill is hidden.
|
|
||||||
expect(text).toMatch(/1.*1/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives review summary counts and renders activity rows", () => {
|
|
||||||
render(makeData());
|
|
||||||
const text = container?.textContent ?? "";
|
|
||||||
|
|
||||||
expect(text).toContain("alicek");
|
|
||||||
expect(text).toContain("Approved");
|
|
||||||
expect(text).toContain("bmartin");
|
|
||||||
expect(text).toContain("Commented");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
|
||||||
import type {
|
import type {
|
||||||
CheckStatus,
|
CheckStatus,
|
||||||
PrPaneActivity,
|
PrPaneActivity,
|
||||||
@@ -283,13 +284,6 @@ function ActivityRow({ item }: { item: PrPaneActivity }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivityVerb(item: PrPaneActivity): string {
|
|
||||||
if (item.kind === "comment") return "Commented";
|
|
||||||
if (item.reviewState === "approved") return "Approved";
|
|
||||||
if (item.reviewState === "changes_requested") return "Requested changes";
|
|
||||||
return "Reviewed";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
||||||
if (state === "open") return theme.colors.statusSuccess;
|
if (state === "open") return theme.colors.statusSuccess;
|
||||||
if (state === "draft") return theme.colors.foregroundMuted;
|
if (state === "draft") return theme.colors.foregroundMuted;
|
||||||
@@ -304,13 +298,6 @@ function getStateIcon(state: PrState) {
|
|||||||
return GitPullRequest;
|
return GitPullRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStateLabel(state: PrState): string {
|
|
||||||
if (state === "draft") return "Draft";
|
|
||||||
if (state === "merged") return "Merged";
|
|
||||||
if (state === "closed") return "Closed";
|
|
||||||
return "Open";
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
root: {
|
root: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function Switch({
|
|||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
accessibilityRole="switch"
|
accessibilityRole="switch"
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
|
aria-checked={value}
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ describe("desktop attachment store", () => {
|
|||||||
await store.garbageCollect({ referencedIds: new Set(["att_3"]) });
|
await store.garbageCollect({ referencedIds: new Set(["att_3"]) });
|
||||||
|
|
||||||
expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg");
|
expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg");
|
||||||
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalled();
|
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalledWith(attachment);
|
||||||
expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" });
|
expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" });
|
||||||
expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({
|
expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({
|
||||||
path: "/managed/att_3.jpg",
|
path: "/managed/att_3.jpg",
|
||||||
|
|||||||
@@ -1,362 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
|
||||||
import React from "react";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { LocalDaemonSection } from "./desktop-updates-section";
|
|
||||||
|
|
||||||
const { alertMock, confirmDialogMock, settingsState, daemonStatusState } = vi.hoisted(() => ({
|
|
||||||
alertMock: vi.fn(),
|
|
||||||
confirmDialogMock: vi.fn(),
|
|
||||||
settingsState: {
|
|
||||||
settings: {
|
|
||||||
releaseChannel: "stable" as const,
|
|
||||||
daemon: {
|
|
||||||
manageBuiltInDaemon: true,
|
|
||||||
keepRunningAfterQuit: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
updateSettings: vi.fn<
|
|
||||||
(updates: {
|
|
||||||
daemon?: {
|
|
||||||
manageBuiltInDaemon?: boolean;
|
|
||||||
keepRunningAfterQuit?: boolean;
|
|
||||||
};
|
|
||||||
}) => Promise<void>
|
|
||||||
>(),
|
|
||||||
},
|
|
||||||
daemonStatusState: {
|
|
||||||
data: {
|
|
||||||
status: {
|
|
||||||
serverId: "desktop",
|
|
||||||
status: "running" as const,
|
|
||||||
listen: null,
|
|
||||||
hostname: null,
|
|
||||||
pid: 123,
|
|
||||||
home: "/tmp/paseo",
|
|
||||||
version: "1.2.3",
|
|
||||||
desktopManaged: true,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
logs: {
|
|
||||||
logPath: "/tmp/paseo/daemon.log",
|
|
||||||
contents: "daemon log",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
error: null as string | null,
|
|
||||||
setStatus: vi.fn(),
|
|
||||||
refetch: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native", () => ({
|
|
||||||
ActivityIndicator: () => React.createElement("div", { "data-testid": "loading-spinner" }),
|
|
||||||
Alert: { alert: alertMock },
|
|
||||||
Text: ({ children }: { children?: React.ReactNode }) =>
|
|
||||||
React.createElement("span", null, children),
|
|
||||||
View: ({ children }: { children?: React.ReactNode }) =>
|
|
||||||
React.createElement("div", null, children),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function"
|
|
||||||
? (factory as (theme: Record<string, unknown>) => unknown)({
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 6: 24 },
|
|
||||||
borderRadius: { lg: 12 },
|
|
||||||
fontSize: { xs: 12, sm: 14 },
|
|
||||||
colors: {
|
|
||||||
foreground: "#111",
|
|
||||||
foregroundMuted: "#666",
|
|
||||||
palette: { amber: { 500: "#f59e0b" } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: factory,
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({
|
|
||||||
theme: {
|
|
||||||
iconSize: { sm: 14 },
|
|
||||||
colors: {
|
|
||||||
foreground: "#111",
|
|
||||||
foregroundMuted: "#666",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("expo-clipboard", () => ({
|
|
||||||
setStringAsync: vi.fn(() => Promise.resolve()),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const icon = (name: string) => () => React.createElement("span", { "data-icon": name });
|
|
||||||
return {
|
|
||||||
Activity: icon("Activity"),
|
|
||||||
ArrowUpRight: icon("ArrowUpRight"),
|
|
||||||
Copy: icon("Copy"),
|
|
||||||
FileText: icon("FileText"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/styles/settings", () => ({
|
|
||||||
settingsStyles: new Proxy(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
get: (_target, prop) => String(prop),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/screens/settings/settings-section", () => ({
|
|
||||||
SettingsSection: ({
|
|
||||||
children,
|
|
||||||
title,
|
|
||||||
trailing,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
trailing?: React.ReactNode;
|
|
||||||
}) =>
|
|
||||||
React.createElement(
|
|
||||||
"section",
|
|
||||||
null,
|
|
||||||
React.createElement("h2", null, title),
|
|
||||||
trailing,
|
|
||||||
children,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
|
||||||
AdaptiveModalSheet: ({
|
|
||||||
children,
|
|
||||||
visible,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
visible?: boolean;
|
|
||||||
title?: string;
|
|
||||||
}) => (visible ? React.createElement("div", { "data-title": title }, children) : null),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/button", () => ({
|
|
||||||
Button: ({
|
|
||||||
children,
|
|
||||||
onPress,
|
|
||||||
disabled,
|
|
||||||
accessibilityLabel,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
onPress?: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
accessibilityLabel?: string;
|
|
||||||
}) =>
|
|
||||||
React.createElement(
|
|
||||||
"button",
|
|
||||||
{ type: "button", onClick: onPress, disabled, "aria-label": accessibilityLabel },
|
|
||||||
children,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/switch", () => ({
|
|
||||||
Switch: ({
|
|
||||||
value,
|
|
||||||
onValueChange,
|
|
||||||
disabled,
|
|
||||||
accessibilityLabel,
|
|
||||||
}: {
|
|
||||||
value: boolean;
|
|
||||||
onValueChange?: (next: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
accessibilityLabel?: string;
|
|
||||||
}) =>
|
|
||||||
React.createElement("button", {
|
|
||||||
type: "button",
|
|
||||||
role: "switch",
|
|
||||||
"aria-label": accessibilityLabel,
|
|
||||||
"aria-checked": value,
|
|
||||||
disabled,
|
|
||||||
onClick: () => onValueChange?.(!value),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/desktop/settings/desktop-settings", () => ({
|
|
||||||
useDesktopSettings: () => ({
|
|
||||||
...settingsState,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/desktop/hooks/use-daemon-status", () => ({
|
|
||||||
useDaemonStatus: () => daemonStatusState,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/confirm-dialog", () => ({
|
|
||||||
confirmDialog: confirmDialogMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/open-external-url", () => ({
|
|
||||||
openExternalUrl: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/desktop/updates/desktop-updates", () => ({
|
|
||||||
isVersionMismatch: vi.fn(() => false),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const daemonCommandMocks = vi.hoisted(() => ({
|
|
||||||
getCliDaemonStatusMock: vi.fn(),
|
|
||||||
startDesktopDaemonMock: vi.fn(),
|
|
||||||
stopDesktopDaemonMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
|
|
||||||
getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock,
|
|
||||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
|
||||||
startDesktopDaemon: daemonCommandMocks.startDesktopDaemonMock,
|
|
||||||
stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/app-version", () => ({
|
|
||||||
resolveAppVersion: vi.fn(() => "1.2.3"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("LocalDaemonSection", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
alertMock.mockReset();
|
|
||||||
confirmDialogMock.mockReset();
|
|
||||||
settingsState.settings.daemon.manageBuiltInDaemon = true;
|
|
||||||
settingsState.settings.daemon.keepRunningAfterQuit = true;
|
|
||||||
settingsState.updateSettings.mockReset();
|
|
||||||
settingsState.updateSettings.mockResolvedValue();
|
|
||||||
daemonStatusState.data.status.status = "running";
|
|
||||||
daemonStatusState.data.status.desktopManaged = true;
|
|
||||||
daemonStatusState.setStatus.mockReset();
|
|
||||||
daemonStatusState.refetch.mockReset();
|
|
||||||
daemonCommandMocks.startDesktopDaemonMock.mockReset();
|
|
||||||
daemonCommandMocks.startDesktopDaemonMock.mockResolvedValue({
|
|
||||||
...daemonStatusState.data.status,
|
|
||||||
status: "running",
|
|
||||||
desktopManaged: true,
|
|
||||||
});
|
|
||||||
daemonCommandMocks.stopDesktopDaemonMock.mockReset();
|
|
||||||
daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({
|
|
||||||
...daemonStatusState.data.status,
|
|
||||||
status: "stopped",
|
|
||||||
});
|
|
||||||
daemonCommandMocks.getCliDaemonStatusMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the daemon toggles as switches reflecting current settings", () => {
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
expect(screen.getByText("Keep daemon running after quit")).toBeTruthy();
|
|
||||||
expect(screen.getByText("Manage built-in daemon")).toBeTruthy();
|
|
||||||
|
|
||||||
const keepRunningSwitch = screen.getByRole("switch", {
|
|
||||||
name: "Keep daemon running after quit",
|
|
||||||
});
|
|
||||||
expect(keepRunningSwitch.getAttribute("aria-checked")).toBe("true");
|
|
||||||
|
|
||||||
const manageSwitch = screen.getByRole("switch", { name: "Manage built-in daemon" });
|
|
||||||
expect(manageSwitch.getAttribute("aria-checked")).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render a duplicate restart-daemon control", () => {
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
expect(screen.queryByText("Restart daemon")).toBeNull();
|
|
||||||
expect(screen.queryByText("Start daemon")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates keep-running-after-quit without changing daemon lifecycle", async () => {
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "Keep daemon running after quit" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
|
||||||
daemon: {
|
|
||||||
keepRunningAfterQuit: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(confirmDialogMock).not.toHaveBeenCalled();
|
|
||||||
expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pauses built-in daemon management and persists the setting through desktop settings", async () => {
|
|
||||||
confirmDialogMock.mockResolvedValue(true);
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
|
||||||
daemon: {
|
|
||||||
manageBuiltInDaemon: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists paused management before stopping the desktop-managed daemon", async () => {
|
|
||||||
confirmDialogMock.mockResolvedValue(true);
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(settingsState.updateSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
|
||||||
daemonCommandMocks.stopDesktopDaemonMock.mock.invocationCallOrder[0],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not stop a manually managed daemon when pausing built-in daemon management", async () => {
|
|
||||||
confirmDialogMock.mockResolvedValue(true);
|
|
||||||
daemonStatusState.data.status.desktopManaged = false;
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
|
||||||
daemon: {
|
|
||||||
manageBuiltInDaemon: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts the built-in daemon when management is re-enabled", async () => {
|
|
||||||
settingsState.settings.daemon.manageBuiltInDaemon = false;
|
|
||||||
const screen = render(<LocalDaemonSection />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(daemonCommandMocks.startDesktopDaemonMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
|
||||||
daemon: {
|
|
||||||
manageBuiltInDaemon: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(daemonStatusState.setStatus).toHaveBeenCalledWith({
|
|
||||||
...daemonStatusState.data.status,
|
|
||||||
status: "running",
|
|
||||||
desktopManaged: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
startDesktopDaemon,
|
startDesktopDaemon,
|
||||||
stopDesktopDaemon,
|
stopDesktopDaemon,
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { executeDaemonManagementToggle } from "@/desktop/daemon/daemon-management-toggle";
|
||||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||||
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
|
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||||
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||||
@@ -39,72 +40,42 @@ function useDaemonManagementToggle(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.manageBuiltInDaemon) {
|
setIsUpdatingDaemonManagement(true);
|
||||||
setIsUpdatingDaemonManagement(true);
|
void executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, {
|
||||||
void updateSettings({ manageBuiltInDaemon: true })
|
confirm: () =>
|
||||||
.then(() => startDesktopDaemon())
|
confirmDialog({
|
||||||
.then((newStatus) => {
|
title: "Pause built-in daemon",
|
||||||
setStatus(newStatus);
|
message:
|
||||||
refetch();
|
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
|
||||||
return;
|
confirmLabel: "Pause and stop",
|
||||||
})
|
cancelLabel: "Cancel",
|
||||||
.catch((error) => {
|
destructive: true,
|
||||||
console.error("[Settings] Failed to update built-in daemon management", error);
|
}),
|
||||||
Alert.alert("Error", "Unable to update built-in daemon management.");
|
persistSettings: (next) => updateSettings(next) as Promise<void>,
|
||||||
})
|
startDaemon: startDesktopDaemon,
|
||||||
.finally(() => {
|
stopDaemon: stopDesktopDaemon,
|
||||||
setIsUpdatingDaemonManagement(false);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void confirmDialog({
|
|
||||||
title: "Pause built-in daemon",
|
|
||||||
message:
|
|
||||||
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
|
|
||||||
confirmLabel: "Pause and stop",
|
|
||||||
cancelLabel: "Cancel",
|
|
||||||
destructive: true,
|
|
||||||
})
|
})
|
||||||
.then((confirmed) => {
|
.then((result) => {
|
||||||
if (!confirmed) {
|
if (result.kind === "cancelled") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.newStatus) {
|
||||||
setIsUpdatingDaemonManagement(true);
|
setStatus(result.newStatus);
|
||||||
|
}
|
||||||
void updateSettings({ manageBuiltInDaemon: false })
|
refetch();
|
||||||
.then(() => {
|
|
||||||
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
|
|
||||||
return stopDesktopDaemon();
|
|
||||||
}
|
|
||||||
return daemonStatus ?? null;
|
|
||||||
})
|
|
||||||
.then((newStatus) => {
|
|
||||||
if (newStatus) {
|
|
||||||
setStatus(newStatus);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
refetch();
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Settings] Failed to stop built-in daemon", error);
|
|
||||||
Alert.alert(
|
|
||||||
"Error",
|
|
||||||
"Built-in daemon management was paused, but Paseo could not stop the daemon.",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsUpdatingDaemonManagement(false);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
|
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
settings.manageBuiltInDaemon
|
||||||
|
? "Built-in daemon management was paused, but Paseo could not stop the daemon."
|
||||||
|
: "Unable to update built-in daemon management.",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsUpdatingDaemonManagement(false);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
daemonStatus,
|
daemonStatus,
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { executeDaemonManagementToggle } from "./daemon-management-toggle";
|
||||||
|
import type { DesktopDaemonStatus } from "./desktop-daemon";
|
||||||
|
|
||||||
|
const runningManagedStatus: DesktopDaemonStatus = {
|
||||||
|
serverId: "desktop",
|
||||||
|
status: "running",
|
||||||
|
listen: null,
|
||||||
|
hostname: null,
|
||||||
|
pid: 123,
|
||||||
|
home: "/tmp/paseo",
|
||||||
|
version: "1.0.0",
|
||||||
|
desktopManaged: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stoppedStatus: DesktopDaemonStatus = { ...runningManagedStatus, status: "stopped" };
|
||||||
|
|
||||||
|
function makeDeps(overrides?: {
|
||||||
|
confirm?: () => Promise<boolean>;
|
||||||
|
persistSettings?: (next: { manageBuiltInDaemon: boolean }) => Promise<void>;
|
||||||
|
startDaemon?: () => Promise<DesktopDaemonStatus>;
|
||||||
|
stopDaemon?: () => Promise<DesktopDaemonStatus>;
|
||||||
|
}) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
return {
|
||||||
|
deps: {
|
||||||
|
confirm: overrides?.confirm ?? (() => Promise.resolve(true)),
|
||||||
|
persistSettings:
|
||||||
|
overrides?.persistSettings ??
|
||||||
|
(async () => {
|
||||||
|
calls.push("persist");
|
||||||
|
}),
|
||||||
|
startDaemon:
|
||||||
|
overrides?.startDaemon ??
|
||||||
|
(async () => {
|
||||||
|
calls.push("start");
|
||||||
|
return { ...runningManagedStatus, status: "running" as const };
|
||||||
|
}),
|
||||||
|
stopDaemon:
|
||||||
|
overrides?.stopDaemon ??
|
||||||
|
(async () => {
|
||||||
|
calls.push("stop");
|
||||||
|
return stoppedStatus;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("executeDaemonManagementToggle", () => {
|
||||||
|
describe("enable path (currentlyManaging: false)", () => {
|
||||||
|
it("persists the new setting then starts the daemon", async () => {
|
||||||
|
const { deps, calls } = makeDeps();
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(false, null, deps);
|
||||||
|
|
||||||
|
expect(result).toEqual({ kind: "enabled", newStatus: runningManagedStatus });
|
||||||
|
expect(calls).toEqual(["persist", "start"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists manageBuiltInDaemon: true", async () => {
|
||||||
|
let persisted: { manageBuiltInDaemon: boolean } | null = null;
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
persistSettings: async (next) => {
|
||||||
|
persisted = next;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeDaemonManagementToggle(false, null, deps);
|
||||||
|
|
||||||
|
expect(persisted).toEqual({ manageBuiltInDaemon: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disable path (currentlyManaging: true)", () => {
|
||||||
|
it("returns cancelled without changing settings when confirmation is rejected", async () => {
|
||||||
|
const persistedSettings: unknown[] = [];
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
confirm: () => Promise.resolve(false),
|
||||||
|
persistSettings: async (next) => {
|
||||||
|
persistedSettings.push(next);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(true, runningManagedStatus, deps);
|
||||||
|
|
||||||
|
expect(result).toEqual({ kind: "cancelled" });
|
||||||
|
expect(persistedSettings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists settings BEFORE stopping the daemon", async () => {
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
persistSettings: async () => {
|
||||||
|
callOrder.push("persist");
|
||||||
|
},
|
||||||
|
stopDaemon: async () => {
|
||||||
|
callOrder.push("stop");
|
||||||
|
return stoppedStatus;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeDaemonManagementToggle(true, runningManagedStatus, deps);
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(["persist", "stop"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists manageBuiltInDaemon: false when disabling", async () => {
|
||||||
|
let persisted: { manageBuiltInDaemon: boolean } | null = null;
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
persistSettings: async (next) => {
|
||||||
|
persisted = next;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeDaemonManagementToggle(true, runningManagedStatus, deps);
|
||||||
|
|
||||||
|
expect(persisted).toEqual({ manageBuiltInDaemon: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the daemon when it is running and desktop-managed", async () => {
|
||||||
|
const { deps, calls } = makeDeps();
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(true, runningManagedStatus, deps);
|
||||||
|
|
||||||
|
expect(calls).toContain("stop");
|
||||||
|
expect(result).toEqual({ kind: "disabled", newStatus: stoppedStatus });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips stop when daemon is running but not desktop-managed", async () => {
|
||||||
|
const { deps, calls } = makeDeps();
|
||||||
|
const manuallyManagedStatus = { ...runningManagedStatus, desktopManaged: false };
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(true, manuallyManagedStatus, deps);
|
||||||
|
|
||||||
|
expect(calls).not.toContain("stop");
|
||||||
|
expect(result).toEqual({ kind: "disabled", newStatus: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips stop when daemon is stopped (regardless of desktopManaged)", async () => {
|
||||||
|
const { deps, calls } = makeDeps();
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(true, stoppedStatus, deps);
|
||||||
|
|
||||||
|
expect(calls).not.toContain("stop");
|
||||||
|
expect(result).toEqual({ kind: "disabled", newStatus: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips stop when daemonStatus is null", async () => {
|
||||||
|
const { deps, calls } = makeDeps();
|
||||||
|
|
||||||
|
const result = await executeDaemonManagementToggle(true, null, deps);
|
||||||
|
|
||||||
|
expect(calls).not.toContain("stop");
|
||||||
|
expect(result).toEqual({ kind: "disabled", newStatus: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { DesktopDaemonStatus } from "./desktop-daemon";
|
||||||
|
|
||||||
|
export interface DaemonManagementToggleDeps {
|
||||||
|
confirm: () => Promise<boolean>;
|
||||||
|
persistSettings: (next: { manageBuiltInDaemon: boolean }) => Promise<void>;
|
||||||
|
startDaemon: () => Promise<DesktopDaemonStatus>;
|
||||||
|
stopDaemon: () => Promise<DesktopDaemonStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DaemonManagementToggleResult =
|
||||||
|
| { kind: "cancelled" }
|
||||||
|
| { kind: "enabled"; newStatus: DesktopDaemonStatus }
|
||||||
|
| { kind: "disabled"; newStatus: DesktopDaemonStatus | null };
|
||||||
|
|
||||||
|
export async function executeDaemonManagementToggle(
|
||||||
|
currentlyManaging: boolean,
|
||||||
|
daemonStatus: Pick<DesktopDaemonStatus, "status" | "desktopManaged"> | null,
|
||||||
|
deps: DaemonManagementToggleDeps,
|
||||||
|
): Promise<DaemonManagementToggleResult> {
|
||||||
|
if (!currentlyManaging) {
|
||||||
|
await deps.persistSettings({ manageBuiltInDaemon: true });
|
||||||
|
const newStatus = await deps.startDaemon();
|
||||||
|
return { kind: "enabled", newStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await deps.confirm();
|
||||||
|
if (!confirmed) {
|
||||||
|
return { kind: "cancelled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings must persist before the daemon is stopped so the persisted
|
||||||
|
// state reflects what was actually applied if the stop fails.
|
||||||
|
await deps.persistSettings({ manageBuiltInDaemon: false });
|
||||||
|
|
||||||
|
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
|
||||||
|
const newStatus = await deps.stopDaemon();
|
||||||
|
return { kind: "disabled", newStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "disabled", newStatus: null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,967 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveAgentForm,
|
||||||
|
resolveFormState,
|
||||||
|
resolveThinkingOptionId,
|
||||||
|
mergeSelectedComposerPreferences,
|
||||||
|
combineInitialValues,
|
||||||
|
buildProviderDefinitionMap,
|
||||||
|
buildProviderDefinitionMapForStatuses,
|
||||||
|
resolveDefaultModel,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
type AgentFormReducerState,
|
||||||
|
type UserModifiedFields,
|
||||||
|
} from "./resolve-agent-form";
|
||||||
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
|
import type {
|
||||||
|
AgentModelDefinition,
|
||||||
|
AgentProvider,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
|
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
description: "Codex test provider",
|
||||||
|
defaultModeId: "auto",
|
||||||
|
modes: [
|
||||||
|
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
||||||
|
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
||||||
|
id: "claude",
|
||||||
|
label: "Claude",
|
||||||
|
description: "Claude test provider",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
|
||||||
|
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
|
||||||
|
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
|
||||||
|
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CODEX_MODELS: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.3-codex",
|
||||||
|
label: "gpt-5.3-codex",
|
||||||
|
isDefault: true,
|
||||||
|
defaultThinkingOptionId: "xhigh",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "low" },
|
||||||
|
{ id: "xhigh", label: "xhigh", isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeProviderMap(
|
||||||
|
...definitions: AgentProviderDefinition[]
|
||||||
|
): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
return new Map(definitions.map((d) => [d.id, d]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
||||||
|
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
|
||||||
|
const bothProviderMap = makeProviderMap(TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION);
|
||||||
|
|
||||||
|
function makeState(
|
||||||
|
overrides: Partial<AgentFormReducerState["form"]> = {},
|
||||||
|
modified: Partial<UserModifiedFields> = {},
|
||||||
|
): AgentFormReducerState {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
serverId: null,
|
||||||
|
provider: null,
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
userModified: { ...INITIAL_USER_MODIFIED, ...modified },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveDefaultModel", () => {
|
||||||
|
it("returns null for empty or null input", () => {
|
||||||
|
expect(resolveDefaultModel(null)).toBeNull();
|
||||||
|
expect(resolveDefaultModel([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the model marked isDefault", () => {
|
||||||
|
const models: AgentModelDefinition[] = [
|
||||||
|
{ provider: "codex", id: "a", label: "A", isDefault: false },
|
||||||
|
{ provider: "codex", id: "b", label: "B", isDefault: true },
|
||||||
|
];
|
||||||
|
expect(resolveDefaultModel(models)?.id).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first model when none is marked default", () => {
|
||||||
|
const models: AgentModelDefinition[] = [
|
||||||
|
{ provider: "codex", id: "a", label: "A", isDefault: false },
|
||||||
|
{ provider: "codex", id: "b", label: "B", isDefault: false },
|
||||||
|
];
|
||||||
|
expect(resolveDefaultModel(models)?.id).toBe("a");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveThinkingOptionId", () => {
|
||||||
|
it("returns empty string when model has no thinking options", () => {
|
||||||
|
const modelsWithoutThinking: AgentModelDefinition[] = [
|
||||||
|
{ provider: "claude", id: "claude-sonnet-4-6", label: "Sonnet 4.6", isDefault: true },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: modelsWithoutThinking,
|
||||||
|
modelId: "claude-sonnet-4-6",
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
}),
|
||||||
|
).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the requested option when it is valid", () => {
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
requestedThinkingOptionId: "low",
|
||||||
|
}),
|
||||||
|
).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to defaultThinkingOptionId when requested option is invalid", () => {
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
requestedThinkingOptionId: "invalid",
|
||||||
|
}),
|
||||||
|
).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to first option when no default and requested is invalid", () => {
|
||||||
|
const modelsNoDefault: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "m",
|
||||||
|
label: "M",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "high", label: "High" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
resolveThinkingOptionId({
|
||||||
|
availableModels: modelsNoDefault,
|
||||||
|
modelId: "m",
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
}),
|
||||||
|
).toBe("low");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combineInitialValues", () => {
|
||||||
|
it("returns undefined when no initial values and no initial server id", () => {
|
||||||
|
expect(combineInitialValues(undefined, null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inject a null serverId override when initialValues are present but serverId is absent", () => {
|
||||||
|
const combined = combineInitialValues({}, null);
|
||||||
|
expect(combined).toEqual({});
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects serverId from options when provided", () => {
|
||||||
|
expect(combineInitialValues({}, "daemon-1")).toEqual({ serverId: "daemon-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps other initial values without forcing serverId", () => {
|
||||||
|
const combined = combineInitialValues({ workingDir: "/repo" }, null);
|
||||||
|
expect(combined).toEqual({ workingDir: "/repo" });
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects an explicit serverId override (including null) over initialServerId", () => {
|
||||||
|
expect(combineInitialValues({ serverId: null }, "daemon-1")).toEqual({ serverId: null });
|
||||||
|
expect(combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({
|
||||||
|
serverId: "daemon-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeSelectedComposerPreferences", () => {
|
||||||
|
it("stores the selected model for the selected provider", () => {
|
||||||
|
expect(
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: {},
|
||||||
|
provider: "codex",
|
||||||
|
updates: { model: "gpt-5.4" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: { codex: { model: "gpt-5.4" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing provider preferences when the selected model changes", () => {
|
||||||
|
expect(
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: {
|
||||||
|
provider: "claude",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
||||||
|
featureValues: { fast_mode: true },
|
||||||
|
},
|
||||||
|
claude: { model: "claude-sonnet-4-6" },
|
||||||
|
},
|
||||||
|
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||||
|
},
|
||||||
|
provider: "codex",
|
||||||
|
updates: { model: "gpt-5.4" },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
||||||
|
featureValues: { fast_mode: true },
|
||||||
|
},
|
||||||
|
claude: { model: "claude-sonnet-4-6" },
|
||||||
|
},
|
||||||
|
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores mode and thinking preferences without dropping the selected model", () => {
|
||||||
|
expect(
|
||||||
|
mergeSelectedComposerPreferences({
|
||||||
|
preferences: {
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "auto",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "low" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: "codex",
|
||||||
|
updates: {
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4": "xhigh" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: {
|
||||||
|
codex: {
|
||||||
|
model: "gpt-5.4",
|
||||||
|
mode: "full-access",
|
||||||
|
thinkingByModel: { "gpt-5.4-mini": "low", "gpt-5.4": "xhigh" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildProviderDefinitions", () => {
|
||||||
|
it("returns empty array when snapshot data is unavailable", () => {
|
||||||
|
expect(buildProviderDefinitions(undefined)).toEqual([]);
|
||||||
|
expect(buildProviderDefinitions([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds provider definitions from snapshot metadata", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "zai",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: "ZAI",
|
||||||
|
description: "Claude with ZAI config",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Safe mode",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(buildProviderDefinitions(entries)).toEqual([
|
||||||
|
{
|
||||||
|
id: "zai",
|
||||||
|
label: "ZAI",
|
||||||
|
description: "Claude with ZAI config",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
label: "Default",
|
||||||
|
description: "Safe mode",
|
||||||
|
icon: "ShieldCheck",
|
||||||
|
colorTier: "safe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveFormState", () => {
|
||||||
|
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState().form,
|
||||||
|
|
||||||
|
bothProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
expect(resolved.modeId).toBe("");
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-select a model on fresh drafts without preferences", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex" },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-selects the model's default thinking option when model is preferred but thinking is not", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to model default when saved thinking preference is invalid", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
{ model: "default" },
|
||||||
|
{ provider: "codex" },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit initial thinking option when it is valid", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
{ model: "gpt-5.3-codex", thinkingOptionId: "low" },
|
||||||
|
{ provider: "codex" },
|
||||||
|
CODEX_MODELS,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
codexProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first thinking option when model exposes options without a provider default", () => {
|
||||||
|
const claudeWithThinking: AgentModelDefinition[] = [
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
id: "default",
|
||||||
|
label: "Default (Sonnet 4.6)",
|
||||||
|
isDefault: true,
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "low", label: "Low" },
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "claude", providerPreferences: { claude: { model: "default" } } },
|
||||||
|
claudeWithThinking,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "claude" }).form,
|
||||||
|
|
||||||
|
claudeProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.model).toBe("default");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex" },
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
claudeProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
|
||||||
|
const loadingEntries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "loading",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
||||||
|
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: loadingEntries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: true,
|
||||||
|
modeId: true,
|
||||||
|
model: true,
|
||||||
|
thinkingOptionId: true,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
makeState({
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.3-codex",
|
||||||
|
thinkingOptionId: "xhigh",
|
||||||
|
}).form,
|
||||||
|
|
||||||
|
resolvableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBe("codex");
|
||||||
|
expect(resolved.modeId).toBe("full-access");
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores disabled ready providers when resolving selectable defaults", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: false,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(entries);
|
||||||
|
const selectableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: entries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "claude" },
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
selectableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBe("codex");
|
||||||
|
expect(resolved.modeId).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: false,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(entries);
|
||||||
|
|
||||||
|
const selectableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: entries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
|
||||||
|
expect(providerDefinitions.map((d) => d.id)).toEqual(["codex", "claude"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
||||||
|
const unavailableEntries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "unavailable",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
|
||||||
|
const resolvableProviderMap = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: unavailableEntries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{ ...INITIAL_USER_MODIFIED, provider: true },
|
||||||
|
makeState({
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.3-codex",
|
||||||
|
thinkingOptionId: "xhigh",
|
||||||
|
}).form,
|
||||||
|
|
||||||
|
resolvableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
expect(resolved.modeId).toBe("");
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not force fallback provider when allowed provider map is empty", () => {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{ provider: "codex" },
|
||||||
|
null,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
makeState({ provider: "codex" }).form,
|
||||||
|
|
||||||
|
new Map<AgentProvider, AgentProviderDefinition>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBe("codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveAgentForm", () => {
|
||||||
|
describe("RESOLVE", () => {
|
||||||
|
it("applies resolved provider and mode when no user modifications", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "RESOLVE",
|
||||||
|
initialValues: undefined,
|
||||||
|
preferences: { provider: "codex" },
|
||||||
|
availableModels: null,
|
||||||
|
|
||||||
|
allowedProviderMap: codexProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
expect(next.form.modeId).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same state reference when nothing changed", () => {
|
||||||
|
const state = makeState({ provider: "codex", modeId: "auto" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "RESOLVE",
|
||||||
|
initialValues: undefined,
|
||||||
|
preferences: { provider: "codex" },
|
||||||
|
availableModels: null,
|
||||||
|
|
||||||
|
allowedProviderMap: codexProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not override user-modified provider", () => {
|
||||||
|
const state = makeState({ provider: "codex", modeId: "auto" }, { provider: true });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "RESOLVE",
|
||||||
|
initialValues: undefined,
|
||||||
|
preferences: { provider: "claude" },
|
||||||
|
availableModels: null,
|
||||||
|
|
||||||
|
allowedProviderMap: bothProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_SERVER_ID", () => {
|
||||||
|
it("updates serverId without marking it user-modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_SERVER_ID", value: "host-1" });
|
||||||
|
|
||||||
|
expect(next.form.serverId).toBe("host-1");
|
||||||
|
expect(next.userModified.serverId).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_SERVER_ID_FROM_USER", () => {
|
||||||
|
it("updates serverId and marks it user-modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_SERVER_ID_FROM_USER", value: "host-2" });
|
||||||
|
|
||||||
|
expect(next.form.serverId).toBe("host-2");
|
||||||
|
expect(next.userModified.serverId).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_PROVIDER_FROM_USER", () => {
|
||||||
|
it("switches provider, picks preferred model and mode, marks provider modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerPrefs: { model: "gpt-5.3-codex", mode: "full-access" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(next.form.modeId).toBe("full-access");
|
||||||
|
expect(next.userModified.provider).toBe(true);
|
||||||
|
expect(next.userModified.model).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provider defaults when no prefs", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerPrefs: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.modeId).toBe("auto");
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => {
|
||||||
|
it("sets provider, model, and default mode; marks both modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.provider).toBe("codex");
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(next.form.modeId).toBe("auto");
|
||||||
|
expect(next.userModified.provider).toBe(true);
|
||||||
|
expect(next.userModified.model).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provider default model when modelId is empty", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "",
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects default thinking option for the chosen model", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
providerDef: TEST_CODEX_DEFINITION,
|
||||||
|
providerModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_MODE_FROM_USER", () => {
|
||||||
|
it("updates modeId and marks it modified", () => {
|
||||||
|
const state = makeState({ provider: "codex", modeId: "auto" });
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_MODE_FROM_USER", modeId: "full-access" });
|
||||||
|
|
||||||
|
expect(next.form.modeId).toBe("full-access");
|
||||||
|
expect(next.userModified.modeId).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_MODEL_FROM_USER", () => {
|
||||||
|
it("updates model and resets thinking to model default when thinking is not user-modified", () => {
|
||||||
|
const state = makeState({ provider: "codex", model: "", thinkingOptionId: "" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_MODEL_FROM_USER",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(next.form.thinkingOptionId).toBe("xhigh");
|
||||||
|
expect(next.userModified.model).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves user-chosen thinking option when switching to same model", () => {
|
||||||
|
const state = makeState(
|
||||||
|
{ provider: "codex", model: "gpt-5.3-codex", thinkingOptionId: "low" },
|
||||||
|
{ thinkingOptionId: true },
|
||||||
|
);
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_MODEL_FROM_USER",
|
||||||
|
modelId: "gpt-5.3-codex",
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.thinkingOptionId).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to provider default model when modelId is blank", () => {
|
||||||
|
const state = makeState({ provider: "codex" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_MODEL_FROM_USER",
|
||||||
|
modelId: " ",
|
||||||
|
availableModels: CODEX_MODELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.model).toBe("gpt-5.3-codex");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_THINKING_OPTION_FROM_USER", () => {
|
||||||
|
it("updates thinkingOptionId and marks it modified", () => {
|
||||||
|
const state = makeState({ thinkingOptionId: "xhigh" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_THINKING_OPTION_FROM_USER",
|
||||||
|
thinkingOptionId: "low",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.thinkingOptionId).toBe("low");
|
||||||
|
expect(next.userModified.thinkingOptionId).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_WORKING_DIR", () => {
|
||||||
|
it("updates workingDir without marking it modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, { type: "SET_WORKING_DIR", value: "/home/user/proj" });
|
||||||
|
|
||||||
|
expect(next.form.workingDir).toBe("/home/user/proj");
|
||||||
|
expect(next.userModified.workingDir).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SET_WORKING_DIR_FROM_USER", () => {
|
||||||
|
it("updates workingDir and marks it modified", () => {
|
||||||
|
const state = makeState();
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "SET_WORKING_DIR_FROM_USER",
|
||||||
|
value: "/home/user/proj",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.workingDir).toBe("/home/user/proj");
|
||||||
|
expect(next.userModified.workingDir).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AUTO_SELECT_SERVER", () => {
|
||||||
|
it("sets serverId when currently null", () => {
|
||||||
|
const state = makeState({ serverId: null });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "AUTO_SELECT_SERVER",
|
||||||
|
candidateServerId: "host-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.form.serverId).toBe("host-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not override an already-set serverId", () => {
|
||||||
|
const state = makeState({ serverId: "existing" });
|
||||||
|
const next = resolveAgentForm(state, {
|
||||||
|
type: "AUTO_SELECT_SERVER",
|
||||||
|
candidateServerId: "host-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RESET", () => {
|
||||||
|
it("resets userModified flags while keeping form state", () => {
|
||||||
|
const state = makeState(
|
||||||
|
{ provider: "codex", modeId: "full-access", model: "gpt-5.3-codex" },
|
||||||
|
{ provider: true, modeId: true, model: true },
|
||||||
|
);
|
||||||
|
const next = resolveAgentForm(state, { type: "RESET" });
|
||||||
|
|
||||||
|
expect(next.userModified).toEqual(INITIAL_USER_MODIFIED);
|
||||||
|
expect(next.form).toEqual(state.form);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildProviderDefinitionMap", () => {
|
||||||
|
it("builds a map from provider id to definition", () => {
|
||||||
|
const map = buildProviderDefinitionMap([TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION]);
|
||||||
|
expect(map.get("codex")).toBe(TEST_CODEX_DEFINITION);
|
||||||
|
expect(map.get("claude")).toBe(TEST_CLAUDE_DEFINITION);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildProviderDefinitionMapForStatuses", () => {
|
||||||
|
it("returns all definitions when no snapshot entries", () => {
|
||||||
|
const map = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: undefined,
|
||||||
|
providerDefinitions: [TEST_CODEX_DEFINITION],
|
||||||
|
statuses: new Set(["ready"]),
|
||||||
|
});
|
||||||
|
expect([...map.keys()]).toEqual(["codex"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters to only matching-status enabled providers", () => {
|
||||||
|
const entries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "ready",
|
||||||
|
enabled: true,
|
||||||
|
label: "Codex",
|
||||||
|
description: "",
|
||||||
|
defaultModeId: "auto",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "loading",
|
||||||
|
enabled: true,
|
||||||
|
label: "Claude",
|
||||||
|
description: "",
|
||||||
|
defaultModeId: "default",
|
||||||
|
modes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const map = buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: entries,
|
||||||
|
providerDefinitions: [TEST_CODEX_DEFINITION, TEST_CLAUDE_DEFINITION],
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...map.keys()]).toEqual(["codex"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
|
import type {
|
||||||
|
AgentModelDefinition,
|
||||||
|
AgentProvider,
|
||||||
|
ProviderSnapshotEntry,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
|
import {
|
||||||
|
mergeProviderPreferences,
|
||||||
|
type FormPreferences,
|
||||||
|
type ProviderPreferences,
|
||||||
|
} from "./use-form-preferences";
|
||||||
|
|
||||||
|
export interface FormInitialValues {
|
||||||
|
serverId?: string | null;
|
||||||
|
provider?: AgentProvider;
|
||||||
|
modeId?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
thinkingOptionId?: string | null;
|
||||||
|
workingDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormState {
|
||||||
|
serverId: string | null;
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
modeId: string;
|
||||||
|
model: string;
|
||||||
|
thinkingOptionId: string;
|
||||||
|
workingDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserModifiedFields {
|
||||||
|
serverId: boolean;
|
||||||
|
provider: boolean;
|
||||||
|
modeId: boolean;
|
||||||
|
model: boolean;
|
||||||
|
thinkingOptionId: boolean;
|
||||||
|
workingDir: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentFormReducerState {
|
||||||
|
form: FormState;
|
||||||
|
userModified: UserModifiedFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
||||||
|
serverId: false,
|
||||||
|
provider: false,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
|
||||||
|
|
||||||
|
export const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>([
|
||||||
|
"ready",
|
||||||
|
"loading",
|
||||||
|
]);
|
||||||
|
export const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
||||||
|
|
||||||
|
export type AgentFormAction =
|
||||||
|
| {
|
||||||
|
type: "RESOLVE";
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
preferences: FormPreferences | null;
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||||
|
}
|
||||||
|
| { type: "SET_SERVER_ID"; value: string | null }
|
||||||
|
| { type: "SET_SERVER_ID_FROM_USER"; value: string | null }
|
||||||
|
| {
|
||||||
|
type: "SET_PROVIDER_FROM_USER";
|
||||||
|
provider: AgentProvider;
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER";
|
||||||
|
provider: AgentProvider;
|
||||||
|
modelId: string;
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
}
|
||||||
|
| { type: "SET_MODE_FROM_USER"; modeId: string }
|
||||||
|
| {
|
||||||
|
type: "SET_MODEL_FROM_USER";
|
||||||
|
modelId: string;
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
}
|
||||||
|
| { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string }
|
||||||
|
| { type: "SET_WORKING_DIR"; value: string }
|
||||||
|
| { type: "SET_WORKING_DIR_FROM_USER"; value: string }
|
||||||
|
| { type: "AUTO_SELECT_SERVER"; candidateServerId: string }
|
||||||
|
| { type: "RESET" };
|
||||||
|
|
||||||
|
export function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||||
|
return typeof modelId === "string" ? modelId.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultModel(
|
||||||
|
availableModels: AgentModelDefinition[] | null,
|
||||||
|
): AgentModelDefinition | null {
|
||||||
|
if (!availableModels || availableModels.length === 0) return null;
|
||||||
|
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
||||||
|
return resolveDefaultModel(availableModels)?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveModel(
|
||||||
|
availableModels: AgentModelDefinition[] | null,
|
||||||
|
modelId: string,
|
||||||
|
): AgentModelDefinition | null {
|
||||||
|
if (!availableModels || availableModels.length === 0) return null;
|
||||||
|
const normalizedModelId = modelId.trim();
|
||||||
|
if (!normalizedModelId) return null;
|
||||||
|
return (
|
||||||
|
availableModels.find((model) => model.id === normalizedModelId) ??
|
||||||
|
resolveDefaultModel(availableModels)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveThinkingOptionId(args: {
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
modelId: string;
|
||||||
|
requestedThinkingOptionId: string;
|
||||||
|
}): string {
|
||||||
|
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
|
||||||
|
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
|
if (thinkingOptions.length === 0) return "";
|
||||||
|
|
||||||
|
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
|
||||||
|
if (
|
||||||
|
normalizedThinkingOptionId &&
|
||||||
|
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
|
||||||
|
) {
|
||||||
|
return normalizedThinkingOptionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeSelectedComposerPreferences(args: {
|
||||||
|
preferences: FormPreferences;
|
||||||
|
provider: AgentProvider;
|
||||||
|
updates: Partial<ProviderPreferences>;
|
||||||
|
}): FormPreferences {
|
||||||
|
return mergeProviderPreferences({
|
||||||
|
preferences: args.preferences,
|
||||||
|
provider: args.provider,
|
||||||
|
updates: args.updates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combineInitialValues(
|
||||||
|
initialValues: FormInitialValues | undefined,
|
||||||
|
initialServerId: string | null,
|
||||||
|
): FormInitialValues | undefined {
|
||||||
|
const hasExplicitServerId = initialValues?.serverId !== undefined;
|
||||||
|
const serverIdFromOptions = initialServerId === null ? undefined : initialServerId;
|
||||||
|
|
||||||
|
if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasExplicitServerId) {
|
||||||
|
return { ...initialValues, serverId: initialValues?.serverId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverIdFromOptions !== undefined) {
|
||||||
|
return { ...initialValues, serverId: serverIdFromOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasFormStateChanged(prev: FormState, next: FormState): boolean {
|
||||||
|
return (
|
||||||
|
prev.serverId !== next.serverId ||
|
||||||
|
prev.provider !== next.provider ||
|
||||||
|
prev.modeId !== next.modeId ||
|
||||||
|
prev.model !== next.model ||
|
||||||
|
prev.thinkingOptionId !== next.thinkingOptionId ||
|
||||||
|
prev.workingDir !== next.workingDir
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProviderDefinitionMap(
|
||||||
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
|
): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||||
|
providerDefinitions.map((definition) => [definition.id, definition]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProviderDefinitionMapForStatuses(args: {
|
||||||
|
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
||||||
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
|
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
|
||||||
|
}): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
if (!args.snapshotEntries?.length) {
|
||||||
|
return buildProviderDefinitionMap(args.providerDefinitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingProviders = new Set(
|
||||||
|
args.snapshotEntries
|
||||||
|
.filter((entry) => args.statuses.has(entry.status) && entry.enabled)
|
||||||
|
.map((entry) => entry.provider),
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildProviderDefinitionMap(
|
||||||
|
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProvider(input: {
|
||||||
|
currentProvider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
preferences: FormPreferences | null;
|
||||||
|
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||||
|
}): AgentProvider | null {
|
||||||
|
const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input;
|
||||||
|
if (userModified) {
|
||||||
|
if (
|
||||||
|
currentProvider &&
|
||||||
|
allowedProviderMap.size > 0 &&
|
||||||
|
!allowedProviderMap.has(currentProvider)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return currentProvider;
|
||||||
|
}
|
||||||
|
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||||
|
return initialValues.provider;
|
||||||
|
}
|
||||||
|
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
|
||||||
|
return preferences.provider;
|
||||||
|
}
|
||||||
|
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return currentProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModeId(input: {
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
currentModeId: string;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { provider, userModified, currentModeId, initialValues, providerDef, providerPrefs } =
|
||||||
|
input;
|
||||||
|
if (userModified) return currentModeId;
|
||||||
|
if (!provider) return "";
|
||||||
|
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||||
|
if (
|
||||||
|
typeof initialValues?.modeId === "string" &&
|
||||||
|
initialValues.modeId.length > 0 &&
|
||||||
|
validModeIds.includes(initialValues.modeId)
|
||||||
|
) {
|
||||||
|
return initialValues.modeId;
|
||||||
|
}
|
||||||
|
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
||||||
|
return providerPrefs.mode;
|
||||||
|
}
|
||||||
|
return providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelField(input: {
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
currentModel: string;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
availableModels: AgentModelDefinition[] | null;
|
||||||
|
}): string {
|
||||||
|
const { provider, userModified, currentModel, initialValues, providerPrefs, availableModels } =
|
||||||
|
input;
|
||||||
|
if (userModified) return currentModel;
|
||||||
|
if (!provider) return "";
|
||||||
|
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
||||||
|
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
||||||
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
|
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||||
|
if (initialModel) {
|
||||||
|
return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId;
|
||||||
|
}
|
||||||
|
if (preferredModel) {
|
||||||
|
return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveThinkingOption(input: {
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
userModified: boolean;
|
||||||
|
currentThinkingOptionId: string;
|
||||||
|
modelId: string;
|
||||||
|
initialValues: FormInitialValues | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } =
|
||||||
|
input;
|
||||||
|
if (!provider) return "";
|
||||||
|
if (userModified) return currentThinkingOptionId;
|
||||||
|
const initialThinkingOptionId =
|
||||||
|
typeof initialValues?.thinkingOptionId === "string"
|
||||||
|
? initialValues.thinkingOptionId.trim()
|
||||||
|
: "";
|
||||||
|
const effectiveModelId = modelId.trim();
|
||||||
|
const preferredThinking = effectiveModelId
|
||||||
|
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
||||||
|
: "";
|
||||||
|
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
|
||||||
|
if (preferredThinking.length > 0) return preferredThinking;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFormState(
|
||||||
|
initialValues: FormInitialValues | undefined,
|
||||||
|
preferences: FormPreferences | null,
|
||||||
|
availableModels: AgentModelDefinition[] | null,
|
||||||
|
userModified: UserModifiedFields,
|
||||||
|
currentState: FormState,
|
||||||
|
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
|
||||||
|
): FormState {
|
||||||
|
const result = { ...currentState };
|
||||||
|
|
||||||
|
result.provider = resolveProvider({
|
||||||
|
currentProvider: result.provider,
|
||||||
|
userModified: userModified.provider,
|
||||||
|
initialValues,
|
||||||
|
preferences,
|
||||||
|
allowedProviderMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
|
||||||
|
const providerPrefs = result.provider
|
||||||
|
? preferences?.providerPreferences?.[result.provider]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
result.modeId = resolveModeId({
|
||||||
|
provider: result.provider,
|
||||||
|
userModified: userModified.modeId,
|
||||||
|
currentModeId: result.modeId,
|
||||||
|
initialValues,
|
||||||
|
providerDef,
|
||||||
|
providerPrefs,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.model = resolveModelField({
|
||||||
|
provider: result.provider,
|
||||||
|
userModified: userModified.model,
|
||||||
|
currentModel: result.model,
|
||||||
|
initialValues,
|
||||||
|
providerPrefs,
|
||||||
|
availableModels,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.thinkingOptionId = resolveThinkingOption({
|
||||||
|
provider: result.provider,
|
||||||
|
userModified: userModified.thinkingOptionId,
|
||||||
|
currentThinkingOptionId: result.thinkingOptionId,
|
||||||
|
modelId: result.model,
|
||||||
|
initialValues,
|
||||||
|
providerPrefs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.provider && availableModels) {
|
||||||
|
result.thinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels,
|
||||||
|
modelId: result.model,
|
||||||
|
requestedThinkingOptionId: result.thinkingOptionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userModified.serverId && initialValues?.serverId !== undefined) {
|
||||||
|
result.serverId = initialValues.serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userModified.workingDir && initialValues?.workingDir !== undefined) {
|
||||||
|
result.workingDir = initialValues.workingDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNextModelForProvider(input: {
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { providerModels, providerPrefs } = input;
|
||||||
|
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
|
||||||
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
|
const defaultModelId = resolveDefaultModelId(providerModels);
|
||||||
|
if (preferredModel && (!providerModels || isValidModel(preferredModel))) {
|
||||||
|
return preferredModel;
|
||||||
|
}
|
||||||
|
return defaultModelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNextModeForProvider(input: {
|
||||||
|
providerDef: AgentProviderDefinition | undefined;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
}): string {
|
||||||
|
const { providerDef, providerPrefs } = input;
|
||||||
|
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||||
|
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
||||||
|
return providerPrefs.mode;
|
||||||
|
}
|
||||||
|
return providerDef?.defaultModeId ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNextThinkingOptionForProvider(input: {
|
||||||
|
providerModels: AgentModelDefinition[] | null;
|
||||||
|
providerPrefs: ProviderPrefs | undefined;
|
||||||
|
modelId: string;
|
||||||
|
}): string {
|
||||||
|
const { providerModels, providerPrefs, modelId } = input;
|
||||||
|
const preferredThinking = modelId
|
||||||
|
? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "")
|
||||||
|
: "";
|
||||||
|
return resolveThinkingOptionId({
|
||||||
|
availableModels: providerModels,
|
||||||
|
modelId,
|
||||||
|
requestedThinkingOptionId: preferredThinking,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAgentForm(
|
||||||
|
state: AgentFormReducerState,
|
||||||
|
action: AgentFormAction,
|
||||||
|
): AgentFormReducerState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "RESOLVE": {
|
||||||
|
const resolved = resolveFormState(
|
||||||
|
action.initialValues,
|
||||||
|
action.preferences,
|
||||||
|
action.availableModels,
|
||||||
|
state.userModified,
|
||||||
|
state.form,
|
||||||
|
action.allowedProviderMap,
|
||||||
|
);
|
||||||
|
if (!hasFormStateChanged(state.form, resolved)) return state;
|
||||||
|
return { ...state, form: resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_SERVER_ID":
|
||||||
|
return { ...state, form: { ...state.form, serverId: action.value } };
|
||||||
|
|
||||||
|
case "SET_SERVER_ID_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, serverId: action.value },
|
||||||
|
userModified: { ...state.userModified, serverId: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "SET_PROVIDER_FROM_USER": {
|
||||||
|
const nextModelId = pickNextModelForProvider({
|
||||||
|
providerModels: action.providerModels,
|
||||||
|
providerPrefs: action.providerPrefs,
|
||||||
|
});
|
||||||
|
const nextModeId = pickNextModeForProvider({
|
||||||
|
providerDef: action.providerDef,
|
||||||
|
providerPrefs: action.providerPrefs,
|
||||||
|
});
|
||||||
|
const nextThinkingOptionId = pickNextThinkingOptionForProvider({
|
||||||
|
providerModels: action.providerModels,
|
||||||
|
providerPrefs: action.providerPrefs,
|
||||||
|
modelId: nextModelId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
...state.form,
|
||||||
|
provider: action.provider,
|
||||||
|
modeId: nextModeId,
|
||||||
|
model: nextModelId,
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
},
|
||||||
|
userModified: { ...state.userModified, provider: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_PROVIDER_AND_MODEL_FROM_USER": {
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: action.providerModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: "",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
...state.form,
|
||||||
|
provider: action.provider,
|
||||||
|
model: nextModelId,
|
||||||
|
modeId: action.providerDef?.defaultModeId ?? "",
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
},
|
||||||
|
userModified: { ...state.userModified, provider: true, model: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_MODE_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, modeId: action.modeId },
|
||||||
|
userModified: { ...state.userModified, modeId: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "SET_MODEL_FROM_USER": {
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(action.modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(action.availableModels);
|
||||||
|
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||||
|
availableModels: action.availableModels,
|
||||||
|
modelId: nextModelId,
|
||||||
|
requestedThinkingOptionId: state.userModified.thinkingOptionId
|
||||||
|
? state.form.thinkingOptionId
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
...state.form,
|
||||||
|
model: nextModelId,
|
||||||
|
thinkingOptionId: nextThinkingOptionId,
|
||||||
|
},
|
||||||
|
userModified: { ...state.userModified, model: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SET_THINKING_OPTION_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, thinkingOptionId: action.thinkingOptionId },
|
||||||
|
userModified: { ...state.userModified, thinkingOptionId: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "SET_WORKING_DIR":
|
||||||
|
return { ...state, form: { ...state.form, workingDir: action.value } };
|
||||||
|
|
||||||
|
case "SET_WORKING_DIR_FROM_USER":
|
||||||
|
return {
|
||||||
|
form: { ...state.form, workingDir: action.value },
|
||||||
|
userModified: { ...state.userModified, workingDir: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
case "AUTO_SELECT_SERVER":
|
||||||
|
if (state.form.serverId) return state;
|
||||||
|
return { ...state, form: { ...state.form, serverId: action.candidateServerId } };
|
||||||
|
|
||||||
|
case "RESET":
|
||||||
|
return { ...state, userModified: INITIAL_USER_MODIFIED };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
|
||||||
import type { FormPreferences } from "./use-form-preferences";
|
|
||||||
import { useAgentFormState } from "./use-agent-form-state";
|
|
||||||
|
|
||||||
const { mocks } = vi.hoisted(() => ({
|
|
||||||
mocks: {
|
|
||||||
preferences: {} as FormPreferences,
|
|
||||||
isPreferencesLoading: false,
|
|
||||||
snapshotEntries: [] as ProviderSnapshotEntry[],
|
|
||||||
updatePreferences: vi.fn(),
|
|
||||||
refreshSnapshot: vi.fn(),
|
|
||||||
refetchSnapshotIfStale: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHosts: () => [{ serverId: "host-1" }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./use-form-preferences", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("./use-form-preferences")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useFormPreferences: () => ({
|
|
||||||
preferences: mocks.preferences,
|
|
||||||
isLoading: mocks.isPreferencesLoading,
|
|
||||||
updatePreferences: mocks.updatePreferences,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("./use-providers-snapshot", () => ({
|
|
||||||
useProvidersSnapshot: () => ({
|
|
||||||
entries: mocks.snapshotEntries,
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
isRefreshing: false,
|
|
||||||
error: null,
|
|
||||||
supportsSnapshot: true,
|
|
||||||
refresh: mocks.refreshSnapshot,
|
|
||||||
refetchIfStale: mocks.refetchSnapshotIfStale,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
||||||
value: true,
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useAgentFormState live preference hydration", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
|
||||||
url: "http://localhost",
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, "document", {
|
|
||||||
value: dom.window.document,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, "window", {
|
|
||||||
value: dom.window,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, "navigator", {
|
|
||||||
value: dom.window.navigator,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
mocks.preferences = {};
|
|
||||||
mocks.isPreferencesLoading = false;
|
|
||||||
mocks.snapshotEntries = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "Codex",
|
|
||||||
description: "Codex test provider",
|
|
||||||
defaultModeId: "auto",
|
|
||||||
modes: [
|
|
||||||
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
|
||||||
{
|
|
||||||
id: "full-access",
|
|
||||||
label: "Full Access",
|
|
||||||
icon: "ShieldAlert",
|
|
||||||
colorTier: "dangerous",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
id: "gpt-5.4",
|
|
||||||
label: "gpt-5.4",
|
|
||||||
isDefault: true,
|
|
||||||
defaultThinkingOptionId: "low",
|
|
||||||
thinkingOptions: [
|
|
||||||
{ id: "low", label: "Low" },
|
|
||||||
{ id: "xhigh", label: "XHigh" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "Claude",
|
|
||||||
description: "Claude test provider",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [{ id: "default", label: "Default", icon: "ShieldCheck", colorTier: "safe" }],
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
id: "claude-sonnet-4-6",
|
|
||||||
label: "Sonnet 4.6",
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mocks.updatePreferences.mockReset();
|
|
||||||
mocks.refreshSnapshot.mockReset();
|
|
||||||
mocks.refetchSnapshotIfStale.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hydrates from stored preferences once and ignores later preference writes from other composers", async () => {
|
|
||||||
mocks.preferences = {
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4": "xhigh",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { result, rerender } = renderHook(() =>
|
|
||||||
useAgentFormState({
|
|
||||||
initialServerId: "host-1",
|
|
||||||
isVisible: true,
|
|
||||||
onlineServerIds: ["host-1"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.selectedProvider).toBe("codex");
|
|
||||||
expect(result.current.selectedModel).toBe("gpt-5.4");
|
|
||||||
expect(result.current.selectedMode).toBe("full-access");
|
|
||||||
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
mocks.preferences = {
|
|
||||||
provider: "claude",
|
|
||||||
providerPreferences: {
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
mode: "default",
|
|
||||||
},
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "auto",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4": "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
rerender();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.selectedProvider).toBe("codex");
|
|
||||||
expect(result.current.selectedModel).toBe("gpt-5.4");
|
|
||||||
expect(result.current.selectedMode).toBe("full-access");
|
|
||||||
expect(result.current.selectedThinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the latest preferences when a separate composer hydrates later", async () => {
|
|
||||||
mocks.preferences = {
|
|
||||||
provider: "claude",
|
|
||||||
providerPreferences: {
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
mode: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAgentFormState({
|
|
||||||
initialServerId: "host-1",
|
|
||||||
isVisible: true,
|
|
||||||
onlineServerIds: ["host-1"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.selectedProvider).toBe("claude" as AgentProvider);
|
|
||||||
expect(result.current.selectedModel).toBe("claude-sonnet-4-6");
|
|
||||||
expect(result.current.selectedMode).toBe("default");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,789 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { __private__ } from "./use-agent-form-state";
|
|
||||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
|
||||||
import type {
|
|
||||||
AgentModelDefinition,
|
|
||||||
AgentProvider,
|
|
||||||
ProviderSnapshotEntry,
|
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
|
|
||||||
id: "codex",
|
|
||||||
label: "Codex",
|
|
||||||
description: "Codex test provider",
|
|
||||||
defaultModeId: "auto",
|
|
||||||
modes: [
|
|
||||||
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
|
||||||
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
|
||||||
id: "claude",
|
|
||||||
label: "Claude",
|
|
||||||
description: "Claude test provider",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
|
|
||||||
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
|
|
||||||
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
|
|
||||||
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeProviderMap(
|
|
||||||
...definitions: AgentProviderDefinition[]
|
|
||||||
): Map<AgentProvider, AgentProviderDefinition> {
|
|
||||||
return new Map(definitions.map((d) => [d.id, d]));
|
|
||||||
}
|
|
||||||
|
|
||||||
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
|
||||||
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
|
|
||||||
|
|
||||||
describe("useAgentFormState", () => {
|
|
||||||
describe("buildProviderDefinitions", () => {
|
|
||||||
it("returns empty array when snapshot data is unavailable", () => {
|
|
||||||
expect(buildProviderDefinitions(undefined)).toEqual([]);
|
|
||||||
expect(buildProviderDefinitions([])).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds custom provider definitions from snapshot metadata", () => {
|
|
||||||
const entries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "zai",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "ZAI",
|
|
||||||
description: "Claude with ZAI config",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
id: "default",
|
|
||||||
label: "Default",
|
|
||||||
description: "Safe mode",
|
|
||||||
icon: "ShieldCheck",
|
|
||||||
colorTier: "safe",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: "Claude",
|
|
||||||
description: "Anthropic Claude",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const definitions = buildProviderDefinitions(entries);
|
|
||||||
|
|
||||||
expect(definitions).toEqual([
|
|
||||||
{
|
|
||||||
id: "zai",
|
|
||||||
label: "ZAI",
|
|
||||||
description: "Claude with ZAI config",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
id: "default",
|
|
||||||
label: "Default",
|
|
||||||
description: "Safe mode",
|
|
||||||
icon: "ShieldCheck",
|
|
||||||
colorTier: "safe",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude",
|
|
||||||
label: "Claude",
|
|
||||||
description: "Anthropic Claude",
|
|
||||||
defaultModeId: "default",
|
|
||||||
modes: [
|
|
||||||
{
|
|
||||||
id: "default",
|
|
||||||
label: "Always Ask",
|
|
||||||
icon: "ShieldCheck",
|
|
||||||
colorTier: "safe",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("__private__.combineInitialValues", () => {
|
|
||||||
it("returns undefined when no initial values and no initial server id", () => {
|
|
||||||
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not inject a null serverId override when initialValues are present but serverId is absent", () => {
|
|
||||||
const combined = __private__.combineInitialValues({}, null);
|
|
||||||
expect(combined).toEqual({});
|
|
||||||
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects serverId from options when provided", () => {
|
|
||||||
expect(__private__.combineInitialValues({}, "daemon-1")).toEqual({
|
|
||||||
serverId: "daemon-1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps other initial values without forcing serverId", () => {
|
|
||||||
const combined = __private__.combineInitialValues({ workingDir: "/repo" }, null);
|
|
||||||
expect(combined).toEqual({ workingDir: "/repo" });
|
|
||||||
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects an explicit serverId override (including null) over initialServerId", () => {
|
|
||||||
expect(__private__.combineInitialValues({ serverId: null }, "daemon-1")).toEqual({
|
|
||||||
serverId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(__private__.combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({
|
|
||||||
serverId: "daemon-2",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("__private__.mergeSelectedComposerPreferences", () => {
|
|
||||||
it("stores the selected model for the selected provider", () => {
|
|
||||||
expect(
|
|
||||||
__private__.mergeSelectedComposerPreferences({
|
|
||||||
preferences: {},
|
|
||||||
provider: "codex",
|
|
||||||
updates: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves existing provider preferences when the selected model changes", () => {
|
|
||||||
expect(
|
|
||||||
__private__.mergeSelectedComposerPreferences({
|
|
||||||
preferences: {
|
|
||||||
provider: "claude",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "medium",
|
|
||||||
},
|
|
||||||
featureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
|
||||||
},
|
|
||||||
provider: "codex",
|
|
||||||
updates: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "medium",
|
|
||||||
},
|
|
||||||
featureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores mode and thinking preferences without dropping the selected model", () => {
|
|
||||||
expect(
|
|
||||||
__private__.mergeSelectedComposerPreferences({
|
|
||||||
preferences: {
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "auto",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "low",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
provider: "codex",
|
|
||||||
updates: {
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4": "xhigh",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
|
||||||
providerPreferences: {
|
|
||||||
codex: {
|
|
||||||
model: "gpt-5.4",
|
|
||||||
mode: "full-access",
|
|
||||||
thinkingByModel: {
|
|
||||||
"gpt-5.4-mini": "low",
|
|
||||||
"gpt-5.4": "xhigh",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("__private__.resolveFormState", () => {
|
|
||||||
const codexModels: AgentModelDefinition[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
id: "gpt-5.3-codex",
|
|
||||||
label: "gpt-5.3-codex",
|
|
||||||
isDefault: true,
|
|
||||||
defaultThinkingOptionId: "xhigh",
|
|
||||||
thinkingOptions: [
|
|
||||||
{ id: "low", label: "low" },
|
|
||||||
{ id: "xhigh", label: "xhigh", isDefault: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{},
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: null,
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
makeProviderMap(TEST_CLAUDE_DEFINITION, TEST_CODEX_DEFINITION),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBeNull();
|
|
||||||
expect(resolved.modeId).toBe("");
|
|
||||||
expect(resolved.model).toBe("");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not auto-select a model on fresh drafts without preferences", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("auto-selects the model's default thinking option when model is preferred but thinking is not", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to model default when saved thinking preference is invalid", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' from initial values to the provider default model", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
{ model: "default" },
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes legacy model id 'default' to the provider default model", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
{ model: "default" },
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps an explicit initial thinking option when it is valid", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
{ model: "gpt-5.3-codex", thinkingOptionId: "low" },
|
|
||||||
{ provider: "codex" },
|
|
||||||
codexModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
codexProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("low");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the first thinking option when the model exposes options without a provider default", () => {
|
|
||||||
const claudeModels: AgentModelDefinition[] = [
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
id: "default",
|
|
||||||
label: "Default (Sonnet 4.6)",
|
|
||||||
isDefault: true,
|
|
||||||
thinkingOptions: [
|
|
||||||
{ id: "low", label: "Low" },
|
|
||||||
{ id: "medium", label: "Medium" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "claude", providerPreferences: { claude: { model: "default" } } },
|
|
||||||
claudeModels,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "claude",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
claudeProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.model).toBe("default");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("low");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex" },
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
claudeProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
|
|
||||||
const loadingEntries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "loading",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
|
||||||
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: loadingEntries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{},
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: true,
|
|
||||||
modeId: true,
|
|
||||||
model: true,
|
|
||||||
thinkingOptionId: true,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "full-access",
|
|
||||||
model: "gpt-5.3-codex",
|
|
||||||
thinkingOptionId: "xhigh",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
resolvableProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBe("codex");
|
|
||||||
expect(resolved.modeId).toBe("full-access");
|
|
||||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores disabled ready providers when resolving selectable defaults", () => {
|
|
||||||
const entries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: false,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(entries);
|
|
||||||
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: entries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "claude" },
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
selectableProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBe("codex");
|
|
||||||
expect(resolved.modeId).toBe("auto");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => {
|
|
||||||
const entries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: false,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(entries);
|
|
||||||
|
|
||||||
const selectableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: entries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const providerDefinitionIds = [providerDefinitions[0]?.id, providerDefinitions[1]?.id];
|
|
||||||
const snapshotProviderStates = [
|
|
||||||
{ provider: entries[0]?.provider, enabled: entries[0]?.enabled },
|
|
||||||
{ provider: entries[1]?.provider, enabled: entries[1]?.enabled },
|
|
||||||
];
|
|
||||||
|
|
||||||
expect([...selectableProviderMap.keys()]).toEqual(["codex"]);
|
|
||||||
expect(providerDefinitionIds).toEqual(["codex", "claude"]);
|
|
||||||
expect(snapshotProviderStates).toEqual([
|
|
||||||
{ provider: "codex", enabled: true },
|
|
||||||
{ provider: "claude", enabled: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
|
||||||
const unavailableEntries: ProviderSnapshotEntry[] = [
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
status: "unavailable",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CODEX_DEFINITION.label,
|
|
||||||
description: TEST_CODEX_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CODEX_DEFINITION.modes,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "claude",
|
|
||||||
status: "ready",
|
|
||||||
enabled: true,
|
|
||||||
label: TEST_CLAUDE_DEFINITION.label,
|
|
||||||
description: TEST_CLAUDE_DEFINITION.description,
|
|
||||||
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
|
||||||
modes: TEST_CLAUDE_DEFINITION.modes,
|
|
||||||
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
|
|
||||||
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
|
||||||
snapshotEntries: unavailableEntries,
|
|
||||||
providerDefinitions,
|
|
||||||
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{},
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: true,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "full-access",
|
|
||||||
model: "gpt-5.3-codex",
|
|
||||||
thinkingOptionId: "xhigh",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
resolvableProviderMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBeNull();
|
|
||||||
expect(resolved.modeId).toBe("");
|
|
||||||
expect(resolved.model).toBe("");
|
|
||||||
expect(resolved.thinkingOptionId).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not force fallback provider when allowed provider map is empty", () => {
|
|
||||||
const resolved = __private__.resolveFormState(
|
|
||||||
undefined,
|
|
||||||
{ provider: "codex" },
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: null,
|
|
||||||
provider: "codex",
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
},
|
|
||||||
new Set<string>(),
|
|
||||||
new Map<AgentProvider, AgentProviderDefinition>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(resolved.provider).toBe("codex");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
AgentMode,
|
||||||
@@ -13,49 +13,26 @@ import {
|
|||||||
useFormPreferences,
|
useFormPreferences,
|
||||||
mergeProviderPreferences,
|
mergeProviderPreferences,
|
||||||
type FormPreferences,
|
type FormPreferences,
|
||||||
type ProviderPreferences,
|
|
||||||
} from "./use-form-preferences";
|
} from "./use-form-preferences";
|
||||||
|
import {
|
||||||
|
resolveAgentForm,
|
||||||
|
resolveEffectiveModel,
|
||||||
|
normalizeSelectedModelId,
|
||||||
|
resolveDefaultModelId,
|
||||||
|
mergeSelectedComposerPreferences,
|
||||||
|
combineInitialValues,
|
||||||
|
buildProviderDefinitionMap,
|
||||||
|
buildProviderDefinitionMapForStatuses,
|
||||||
|
INITIAL_USER_MODIFIED,
|
||||||
|
RESOLVABLE_PROVIDER_STATUSES,
|
||||||
|
SELECTABLE_PROVIDER_STATUSES,
|
||||||
|
type FormInitialValues,
|
||||||
|
type FormState,
|
||||||
|
} from "./resolve-agent-form";
|
||||||
|
|
||||||
// Explicit overrides from URL params or "New Agent" button
|
export type { FormInitialValues } from "./resolve-agent-form";
|
||||||
export interface FormInitialValues {
|
|
||||||
serverId?: string | null;
|
|
||||||
provider?: AgentProvider;
|
|
||||||
modeId?: string | null;
|
|
||||||
model?: string | null;
|
|
||||||
thinkingOptionId?: string | null;
|
|
||||||
workingDir?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tracks which fields the user has explicitly modified in this session
|
export interface UseAgentFormStateOptions {
|
||||||
interface UserModifiedFields {
|
|
||||||
serverId: boolean;
|
|
||||||
provider: boolean;
|
|
||||||
modeId: boolean;
|
|
||||||
model: boolean;
|
|
||||||
thinkingOptionId: boolean;
|
|
||||||
workingDir: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
|
||||||
serverId: false,
|
|
||||||
provider: false,
|
|
||||||
modeId: false,
|
|
||||||
model: false,
|
|
||||||
thinkingOptionId: false,
|
|
||||||
workingDir: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Internal form state
|
|
||||||
interface FormState {
|
|
||||||
serverId: string | null;
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
modeId: string;
|
|
||||||
model: string;
|
|
||||||
thinkingOptionId: string;
|
|
||||||
workingDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseAgentFormStateOptions {
|
|
||||||
initialServerId?: string | null;
|
initialServerId?: string | null;
|
||||||
initialValues?: FormInitialValues;
|
initialValues?: FormInitialValues;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
@@ -97,316 +74,6 @@ export interface UseAgentFormStateResult {
|
|||||||
persistFormPreferences: () => Promise<void>;
|
persistFormPreferences: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
|
||||||
if (!normalized) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDefaultModel(
|
|
||||||
availableModels: AgentModelDefinition[] | null,
|
|
||||||
): AgentModelDefinition | null {
|
|
||||||
if (!availableModels || availableModels.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDefaultModelId(availableModels: AgentModelDefinition[] | null): string {
|
|
||||||
return resolveDefaultModel(availableModels)?.id ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveEffectiveModel(
|
|
||||||
availableModels: AgentModelDefinition[] | null,
|
|
||||||
modelId: string,
|
|
||||||
): AgentModelDefinition | null {
|
|
||||||
if (!availableModels || availableModels.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalizedModelId = modelId.trim();
|
|
||||||
if (!normalizedModelId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
availableModels.find((model) => model.id === normalizedModelId) ??
|
|
||||||
resolveDefaultModel(availableModels)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveThinkingOptionId(args: {
|
|
||||||
availableModels: AgentModelDefinition[] | null;
|
|
||||||
modelId: string;
|
|
||||||
requestedThinkingOptionId: string;
|
|
||||||
}): string {
|
|
||||||
const effectiveModel = resolveEffectiveModel(args.availableModels, args.modelId);
|
|
||||||
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
|
||||||
if (thinkingOptions.length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedThinkingOptionId = args.requestedThinkingOptionId.trim();
|
|
||||||
if (
|
|
||||||
normalizedThinkingOptionId &&
|
|
||||||
thinkingOptions.some((option) => option.id === normalizedThinkingOptionId)
|
|
||||||
) {
|
|
||||||
return normalizedThinkingOptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeSelectedComposerPreferences(args: {
|
|
||||||
preferences: FormPreferences;
|
|
||||||
provider: AgentProvider;
|
|
||||||
updates: Partial<ProviderPreferences>;
|
|
||||||
}): FormPreferences {
|
|
||||||
return mergeProviderPreferences({
|
|
||||||
preferences: args.preferences,
|
|
||||||
provider: args.provider,
|
|
||||||
updates: args.updates,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure function that resolves form state from multiple data sources.
|
|
||||||
* Priority: explicit (URL params) > provider defaults > lightweight app prefs > fallback
|
|
||||||
*
|
|
||||||
* Only resolves fields that haven't been user-modified.
|
|
||||||
*/
|
|
||||||
function resolveProvider(input: {
|
|
||||||
currentProvider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
preferences: FormPreferences | null;
|
|
||||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>;
|
|
||||||
}): AgentProvider | null {
|
|
||||||
const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input;
|
|
||||||
if (userModified) {
|
|
||||||
if (
|
|
||||||
currentProvider &&
|
|
||||||
allowedProviderMap.size > 0 &&
|
|
||||||
!allowedProviderMap.has(currentProvider)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return currentProvider;
|
|
||||||
}
|
|
||||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
|
||||||
return initialValues.provider;
|
|
||||||
}
|
|
||||||
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
|
|
||||||
return preferences.provider;
|
|
||||||
}
|
|
||||||
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return currentProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModeId(input: {
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
currentModeId: string;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
providerDef: AgentProviderDefinition | undefined;
|
|
||||||
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
|
||||||
}): string {
|
|
||||||
const { provider, userModified, currentModeId, initialValues, providerDef, providerPrefs } =
|
|
||||||
input;
|
|
||||||
if (userModified) return currentModeId;
|
|
||||||
if (!provider) return "";
|
|
||||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
|
||||||
if (
|
|
||||||
typeof initialValues?.modeId === "string" &&
|
|
||||||
initialValues.modeId.length > 0 &&
|
|
||||||
validModeIds.includes(initialValues.modeId)
|
|
||||||
) {
|
|
||||||
return initialValues.modeId;
|
|
||||||
}
|
|
||||||
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
|
||||||
return providerPrefs.mode;
|
|
||||||
}
|
|
||||||
return providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModelField(input: {
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
currentModel: string;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
|
||||||
availableModels: AgentModelDefinition[] | null;
|
|
||||||
}): string {
|
|
||||||
const { provider, userModified, currentModel, initialValues, providerPrefs, availableModels } =
|
|
||||||
input;
|
|
||||||
if (userModified) return currentModel;
|
|
||||||
if (!provider) return "";
|
|
||||||
const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false;
|
|
||||||
const initialModel = normalizeSelectedModelId(initialValues?.model);
|
|
||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
|
||||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
|
||||||
if (initialModel) {
|
|
||||||
return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId;
|
|
||||||
}
|
|
||||||
if (preferredModel) {
|
|
||||||
return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveThinkingOption(input: {
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
userModified: boolean;
|
|
||||||
currentThinkingOptionId: string;
|
|
||||||
modelId: string;
|
|
||||||
initialValues: FormInitialValues | undefined;
|
|
||||||
providerPrefs: NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
|
||||||
}): string {
|
|
||||||
const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } =
|
|
||||||
input;
|
|
||||||
if (!provider) return "";
|
|
||||||
if (userModified) return currentThinkingOptionId;
|
|
||||||
const initialThinkingOptionId =
|
|
||||||
typeof initialValues?.thinkingOptionId === "string"
|
|
||||||
? initialValues.thinkingOptionId.trim()
|
|
||||||
: "";
|
|
||||||
const effectiveModelId = modelId.trim();
|
|
||||||
const preferredThinking = effectiveModelId
|
|
||||||
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
|
||||||
: "";
|
|
||||||
if (initialThinkingOptionId.length > 0) return initialThinkingOptionId;
|
|
||||||
if (preferredThinking.length > 0) return preferredThinking;
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFormState(
|
|
||||||
initialValues: FormInitialValues | undefined,
|
|
||||||
preferences: FormPreferences | null,
|
|
||||||
availableModels: AgentModelDefinition[] | null,
|
|
||||||
userModified: UserModifiedFields,
|
|
||||||
currentState: FormState,
|
|
||||||
validServerIds: Set<string>,
|
|
||||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
|
|
||||||
): FormState {
|
|
||||||
const result = { ...currentState };
|
|
||||||
|
|
||||||
result.provider = resolveProvider({
|
|
||||||
currentProvider: result.provider,
|
|
||||||
userModified: userModified.provider,
|
|
||||||
initialValues,
|
|
||||||
preferences,
|
|
||||||
allowedProviderMap,
|
|
||||||
});
|
|
||||||
|
|
||||||
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
|
|
||||||
const providerPrefs = result.provider
|
|
||||||
? preferences?.providerPreferences?.[result.provider]
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
result.modeId = resolveModeId({
|
|
||||||
provider: result.provider,
|
|
||||||
userModified: userModified.modeId,
|
|
||||||
currentModeId: result.modeId,
|
|
||||||
initialValues,
|
|
||||||
providerDef,
|
|
||||||
providerPrefs,
|
|
||||||
});
|
|
||||||
|
|
||||||
result.model = resolveModelField({
|
|
||||||
provider: result.provider,
|
|
||||||
userModified: userModified.model,
|
|
||||||
currentModel: result.model,
|
|
||||||
initialValues,
|
|
||||||
providerPrefs,
|
|
||||||
availableModels,
|
|
||||||
});
|
|
||||||
|
|
||||||
result.thinkingOptionId = resolveThinkingOption({
|
|
||||||
provider: result.provider,
|
|
||||||
userModified: userModified.thinkingOptionId,
|
|
||||||
currentThinkingOptionId: result.thinkingOptionId,
|
|
||||||
modelId: result.model,
|
|
||||||
initialValues,
|
|
||||||
providerPrefs,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.provider && availableModels) {
|
|
||||||
result.thinkingOptionId = resolveThinkingOptionId({
|
|
||||||
availableModels,
|
|
||||||
modelId: result.model,
|
|
||||||
requestedThinkingOptionId: result.thinkingOptionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userModified.serverId && initialValues?.serverId !== undefined) {
|
|
||||||
result.serverId = initialValues.serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userModified.workingDir && initialValues?.workingDir !== undefined) {
|
|
||||||
result.workingDir = initialValues.workingDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function combineInitialValues(
|
|
||||||
initialValues: FormInitialValues | undefined,
|
|
||||||
initialServerId: string | null,
|
|
||||||
): FormInitialValues | undefined {
|
|
||||||
const hasExplicitServerId = initialValues?.serverId !== undefined;
|
|
||||||
const serverIdFromOptions = initialServerId === null ? undefined : initialServerId;
|
|
||||||
|
|
||||||
// If nobody provided initial values or an explicit serverId, let preferences drive defaults.
|
|
||||||
if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasExplicitServerId) {
|
|
||||||
return { ...initialValues, serverId: initialValues?.serverId };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serverIdFromOptions !== undefined) {
|
|
||||||
return { ...initialValues, serverId: serverIdFromOptions };
|
|
||||||
}
|
|
||||||
|
|
||||||
return initialValues;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]);
|
|
||||||
const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
|
||||||
|
|
||||||
function buildProviderDefinitionMap(
|
|
||||||
providerDefinitions: AgentProviderDefinition[],
|
|
||||||
): Map<AgentProvider, AgentProviderDefinition> {
|
|
||||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
|
||||||
providerDefinitions.map((definition) => [definition.id, definition]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProviderDefinitionMapForStatuses(args: {
|
|
||||||
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
|
||||||
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
|
|
||||||
}): Map<AgentProvider, AgentProviderDefinition> {
|
|
||||||
if (!args.snapshotEntries?.length) {
|
|
||||||
return buildProviderDefinitionMap(args.providerDefinitions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchingProviders = new Set(
|
|
||||||
args.snapshotEntries
|
|
||||||
.filter((entry) => args.statuses.has(entry.status) && entry.enabled)
|
|
||||||
.map((entry) => entry.provider),
|
|
||||||
);
|
|
||||||
|
|
||||||
return buildProviderDefinitionMap(
|
|
||||||
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProviderPrefs = NonNullable<FormPreferences["providerPreferences"]>[AgentProvider];
|
|
||||||
|
|
||||||
function shouldAutoSelectServerId(input: {
|
function shouldAutoSelectServerId(input: {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
isCreateFlow: boolean;
|
isCreateFlow: boolean;
|
||||||
@@ -434,59 +101,6 @@ function shouldAutoSelectServerId(input: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasFormStateChanged(prev: FormState, next: FormState): boolean {
|
|
||||||
return (
|
|
||||||
prev.serverId !== next.serverId ||
|
|
||||||
prev.provider !== next.provider ||
|
|
||||||
prev.modeId !== next.modeId ||
|
|
||||||
prev.model !== next.model ||
|
|
||||||
prev.thinkingOptionId !== next.thinkingOptionId ||
|
|
||||||
prev.workingDir !== next.workingDir
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickNextModelForProvider(input: {
|
|
||||||
providerModels: AgentModelDefinition[] | null;
|
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
|
||||||
}): string {
|
|
||||||
const { providerModels, providerPrefs } = input;
|
|
||||||
const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false;
|
|
||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
|
||||||
const defaultModelId = resolveDefaultModelId(providerModels);
|
|
||||||
if (preferredModel && (!providerModels || isValidModel(preferredModel))) {
|
|
||||||
return preferredModel;
|
|
||||||
}
|
|
||||||
return defaultModelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickNextModeForProvider(input: {
|
|
||||||
providerDef: AgentProviderDefinition | undefined;
|
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
|
||||||
}): string {
|
|
||||||
const { providerDef, providerPrefs } = input;
|
|
||||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
|
||||||
if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) {
|
|
||||||
return providerPrefs.mode;
|
|
||||||
}
|
|
||||||
return providerDef?.defaultModeId ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickNextThinkingOptionForProvider(input: {
|
|
||||||
providerModels: AgentModelDefinition[] | null;
|
|
||||||
providerPrefs: ProviderPrefs | undefined;
|
|
||||||
modelId: string;
|
|
||||||
}): string {
|
|
||||||
const { providerModels, providerPrefs, modelId } = input;
|
|
||||||
const preferredThinking = modelId
|
|
||||||
? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "")
|
|
||||||
: "";
|
|
||||||
return resolveThinkingOptionId({
|
|
||||||
availableModels: providerModels,
|
|
||||||
modelId,
|
|
||||||
requestedThinkingOptionId: preferredThinking,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSelectedProviderModes(input: {
|
function resolveSelectedProviderModes(input: {
|
||||||
selectedEntry: ProviderSnapshotEntry | null;
|
selectedEntry: ProviderSnapshotEntry | null;
|
||||||
provider: AgentProvider | null;
|
provider: AgentProvider | null;
|
||||||
@@ -552,34 +166,35 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
|
|
||||||
// Build a set of valid server IDs for preference validation
|
|
||||||
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
||||||
|
|
||||||
// Track which fields the user has explicitly modified
|
const [{ form: formState, userModified }, dispatch] = useReducer(
|
||||||
const [userModified, setUserModified] = useState<UserModifiedFields>(INITIAL_USER_MODIFIED);
|
resolveAgentForm,
|
||||||
|
initialServerId,
|
||||||
|
(serverId) => ({
|
||||||
|
form: {
|
||||||
|
serverId,
|
||||||
|
provider: null,
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
userModified: INITIAL_USER_MODIFIED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Form state
|
const reducerStateRef = useRef({ form: formState, userModified });
|
||||||
const [formState, setFormState] = useState<FormState>(() => ({
|
|
||||||
serverId: initialServerId,
|
|
||||||
provider: null,
|
|
||||||
modeId: "",
|
|
||||||
model: "",
|
|
||||||
thinkingOptionId: "",
|
|
||||||
workingDir: "",
|
|
||||||
}));
|
|
||||||
const formStateRef = useRef(formState);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formStateRef.current = formState;
|
reducerStateRef.current = { form: formState, userModified };
|
||||||
}, [formState]);
|
}, [formState, userModified]);
|
||||||
|
|
||||||
// Track if we've done initial resolution (to avoid flickering)
|
|
||||||
const hasResolvedRef = useRef(false);
|
const hasResolvedRef = useRef(false);
|
||||||
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
||||||
|
|
||||||
// Reset user modifications when form becomes invisible
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
setUserModified(INITIAL_USER_MODIFIED);
|
dispatch({ type: "RESET" });
|
||||||
hasResolvedRef.current = false;
|
hasResolvedRef.current = false;
|
||||||
hydrationPreferencesRef.current = null;
|
hydrationPreferencesRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -644,12 +259,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const modeOptions = snapshotSelectedProviderModes;
|
const modeOptions = snapshotSelectedProviderModes;
|
||||||
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||||
|
|
||||||
// Combine initialValues with initialServerId for resolution
|
const combinedInitialValues = useMemo(
|
||||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
() => combineInitialValues(initialValues, initialServerId),
|
||||||
return combineInitialValues(initialValues, initialServerId);
|
[initialValues, initialServerId],
|
||||||
}, [initialValues, initialServerId]);
|
);
|
||||||
|
|
||||||
// Resolve form state when data sources change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible || !isCreateFlow) {
|
if (!isVisible || !isCreateFlow) {
|
||||||
return;
|
return;
|
||||||
@@ -664,19 +278,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}
|
}
|
||||||
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
||||||
|
|
||||||
const resolved = resolveFormState(
|
dispatch({
|
||||||
combinedInitialValues,
|
type: "RESOLVE",
|
||||||
hydrationPreferences,
|
initialValues: combinedInitialValues,
|
||||||
|
preferences: hydrationPreferences,
|
||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
allowedProviderMap: snapshotResolvableProviderDefinitionMap,
|
||||||
formStateRef.current,
|
});
|
||||||
validServerIds,
|
|
||||||
snapshotResolvableProviderDefinitionMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasFormStateChanged(formStateRef.current, resolved)) {
|
|
||||||
setFormState(resolved);
|
|
||||||
}
|
|
||||||
|
|
||||||
hasResolvedRef.current = true;
|
hasResolvedRef.current = true;
|
||||||
}, [
|
}, [
|
||||||
@@ -686,15 +294,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
combinedInitialValues,
|
combinedInitialValues,
|
||||||
preferences,
|
preferences,
|
||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
|
||||||
validServerIds,
|
|
||||||
snapshotResolvableProviderDefinitionMap,
|
snapshotResolvableProviderDefinitionMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-select the first online host when:
|
|
||||||
// - no URL override
|
|
||||||
// - no stored preference applied
|
|
||||||
// - user hasn't manually picked a host in this session
|
|
||||||
const onlineServerIdsKey = onlineServerIds.join("|");
|
const onlineServerIdsKey = onlineServerIds.join("|");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canAutoSelectServerId = shouldAutoSelectServerId({
|
const canAutoSelectServerId = shouldAutoSelectServerId({
|
||||||
@@ -704,14 +306,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
hasResolved: hasResolvedRef.current,
|
hasResolved: hasResolvedRef.current,
|
||||||
userModifiedServerId: userModified.serverId,
|
userModifiedServerId: userModified.serverId,
|
||||||
initialServerId: combinedInitialValues?.serverId,
|
initialServerId: combinedInitialValues?.serverId,
|
||||||
currentServerId: formStateRef.current.serverId,
|
currentServerId: reducerStateRef.current.form.serverId,
|
||||||
});
|
});
|
||||||
if (!canAutoSelectServerId) return;
|
if (!canAutoSelectServerId) return;
|
||||||
|
|
||||||
const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null;
|
const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null;
|
||||||
if (!candidate) return;
|
if (!candidate) return;
|
||||||
|
|
||||||
setFormState((prev) => (prev.serverId ? prev : { ...prev, serverId: candidate }));
|
dispatch({ type: "AUTO_SELECT_SERVER", candidateServerId: candidate });
|
||||||
}, [
|
}, [
|
||||||
combinedInitialValues?.serverId,
|
combinedInitialValues?.serverId,
|
||||||
isCreateFlow,
|
isCreateFlow,
|
||||||
@@ -723,10 +325,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
validServerIds,
|
validServerIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// User setters - mark fields as modified and persist to preferences
|
|
||||||
const setSelectedServerIdFromUser = useCallback((value: string | null) => {
|
const setSelectedServerIdFromUser = useCallback((value: string | null) => {
|
||||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
dispatch({ type: "SET_SERVER_ID_FROM_USER", value });
|
||||||
setUserModified((prev) => ({ ...prev, serverId: true }));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setProviderFromUser = useCallback(
|
const setProviderFromUser = useCallback(
|
||||||
@@ -738,24 +338,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||||
|
|
||||||
const nextModelId = pickNextModelForProvider({ providerModels, providerPrefs });
|
dispatch({
|
||||||
const nextModeId = pickNextModeForProvider({ providerDef, providerPrefs });
|
type: "SET_PROVIDER_FROM_USER",
|
||||||
const nextThinkingOptionId = pickNextThinkingOptionForProvider({
|
|
||||||
providerModels,
|
|
||||||
providerPrefs,
|
|
||||||
modelId: nextModelId,
|
|
||||||
});
|
|
||||||
|
|
||||||
setUserModified((prev) => ({ ...prev, provider: true }));
|
|
||||||
void updatePreferences({ provider });
|
|
||||||
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
provider,
|
provider,
|
||||||
modeId: nextModeId,
|
providerModels,
|
||||||
model: nextModelId,
|
providerDef,
|
||||||
thinkingOptionId: nextThinkingOptionId,
|
providerPrefs,
|
||||||
}));
|
});
|
||||||
|
void updatePreferences({ provider });
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
allProviderModels,
|
allProviderModels,
|
||||||
@@ -774,20 +364,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
const providerModels = allProviderModels.get(provider) ?? null;
|
const providerModels = allProviderModels.get(provider) ?? null;
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
|
||||||
availableModels: providerModels,
|
|
||||||
modelId: nextModelId,
|
|
||||||
requestedThinkingOptionId: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
setFormState((prev) => ({
|
dispatch({
|
||||||
...prev,
|
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||||
provider,
|
provider,
|
||||||
model: nextModelId,
|
modelId,
|
||||||
modeId: providerDef?.defaultModeId ?? "",
|
providerDef,
|
||||||
thinkingOptionId: nextThinkingOptionId,
|
providerModels,
|
||||||
}));
|
});
|
||||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
@@ -803,9 +387,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const setModeFromUser = useCallback(
|
const setModeFromUser = useCallback(
|
||||||
(modeId: string) => {
|
(modeId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, modeId }));
|
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
const provider = reducerStateRef.current.form.provider;
|
||||||
const provider = formStateRef.current.provider;
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
@@ -823,23 +406,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const setModelFromUser = useCallback(
|
const setModelFromUser = useCallback(
|
||||||
(modelId: string) => {
|
(modelId: string) => {
|
||||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
dispatch({ type: "SET_MODEL_FROM_USER", modelId, availableModels });
|
||||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
const provider = reducerStateRef.current.form.provider;
|
||||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
|
||||||
availableModels,
|
|
||||||
modelId: nextModelId,
|
|
||||||
requestedThinkingOptionId: userModified.thinkingOptionId
|
|
||||||
? formStateRef.current.thinkingOptionId
|
|
||||||
: "",
|
|
||||||
});
|
|
||||||
setFormState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
model: nextModelId,
|
|
||||||
thinkingOptionId: nextThinkingOptionId,
|
|
||||||
}));
|
|
||||||
setUserModified((prev) => ({ ...prev, model: true }));
|
|
||||||
const provider = formStateRef.current.provider;
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
|
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||||
|
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
@@ -851,15 +422,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[availableModels, updatePreferences, userModified.thinkingOptionId],
|
[availableModels, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThinkingOptionFromUser = useCallback(
|
const setThinkingOptionFromUser = useCallback(
|
||||||
(thinkingOptionId: string) => {
|
(thinkingOptionId: string) => {
|
||||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
||||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
const { provider, model: modelId } = reducerStateRef.current.form;
|
||||||
const provider = formStateRef.current.provider;
|
|
||||||
const modelId = formStateRef.current.model;
|
|
||||||
if (provider && modelId) {
|
if (provider && modelId) {
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeSelectedComposerPreferences({
|
mergeSelectedComposerPreferences({
|
||||||
@@ -878,16 +447,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
|
|
||||||
const setWorkingDir = useCallback((value: string) => {
|
const setWorkingDir = useCallback((value: string) => {
|
||||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
dispatch({ type: "SET_WORKING_DIR", value });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setWorkingDirFromUser = useCallback((value: string) => {
|
const setWorkingDirFromUser = useCallback((value: string) => {
|
||||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
dispatch({ type: "SET_WORKING_DIR_FROM_USER", value });
|
||||||
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setSelectedServerId = useCallback((value: string | null) => {
|
const setSelectedServerId = useCallback((value: string | null) => {
|
||||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
dispatch({ type: "SET_SERVER_ID", value });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshProviderModels = useCallback(() => {
|
const refreshProviderModels = useCallback(() => {
|
||||||
@@ -895,7 +463,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}, [refreshSnapshot]);
|
}, [refreshSnapshot]);
|
||||||
|
|
||||||
const refetchProviderModelsIfStale = useCallback(() => {
|
const refetchProviderModelsIfStale = useCallback(() => {
|
||||||
refetchSnapshotIfStale(formStateRef.current.provider);
|
refetchSnapshotIfStale(reducerStateRef.current.form.provider);
|
||||||
}, [refetchSnapshotIfStale]);
|
}, [refetchSnapshotIfStale]);
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
@@ -993,15 +561,4 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
|
||||||
export type CreateAgentInitialValues = FormInitialValues;
|
export type CreateAgentInitialValues = FormInitialValues;
|
||||||
|
|
||||||
export const __private__ = {
|
|
||||||
buildProviderDefinitionMap,
|
|
||||||
buildProviderDefinitionMapForStatuses,
|
|
||||||
combineInitialValues,
|
|
||||||
mergeSelectedComposerPreferences,
|
|
||||||
resolveDefaultModel,
|
|
||||||
resolveFormState,
|
|
||||||
resolveThinkingOptionId,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
|
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
||||||
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
|
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||||
|
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
|
export interface DraftStorage {
|
||||||
|
getItem(key: string): Promise<string | null>;
|
||||||
|
setItem(key: string, value: string): Promise<void>;
|
||||||
|
removeItem(key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredDraft {
|
||||||
|
text: string;
|
||||||
|
attachments: UserComposerAttachment[];
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentInputDraftCore {
|
||||||
|
load(initialCwd?: string): Promise<StoredDraft | null>;
|
||||||
|
save(draft: StoredDraft): Promise<void>;
|
||||||
|
clear(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAgentInputDraftCore(input: {
|
||||||
|
storage: DraftStorage;
|
||||||
|
storageKey: string;
|
||||||
|
}): AgentInputDraftCore {
|
||||||
|
return {
|
||||||
|
async load(initialCwd?: string) {
|
||||||
|
const json = await input.storage.getItem(input.storageKey);
|
||||||
|
if (!json) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
text: typeof parsed.text === "string" ? parsed.text : "",
|
||||||
|
attachments: Array.isArray(parsed.attachments)
|
||||||
|
? (parsed.attachments as UserComposerAttachment[])
|
||||||
|
: [],
|
||||||
|
cwd: typeof parsed.cwd === "string" ? parsed.cwd : (initialCwd ?? ""),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async save(draft) {
|
||||||
|
await input.storage.setItem(input.storageKey, JSON.stringify(draft));
|
||||||
|
},
|
||||||
|
async clear() {
|
||||||
|
await input.storage.removeItem(input.storageKey);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftKeyContext {
|
||||||
|
selectedServerId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DraftKeyInput = string | ((context: DraftKeyContext) => string);
|
||||||
|
|
||||||
|
export function resolveDraftKey(input: {
|
||||||
|
draftKey: DraftKeyInput;
|
||||||
|
selectedServerId: string | null;
|
||||||
|
}): string {
|
||||||
|
if (typeof input.draftKey === "function") {
|
||||||
|
return input.draftKey({ selectedServerId: input.selectedServerId });
|
||||||
|
}
|
||||||
|
return input.draftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveComposerModelId(input: {
|
||||||
|
selectedModel: string;
|
||||||
|
availableModels: AgentModelDefinition[];
|
||||||
|
}): string {
|
||||||
|
return input.selectedModel.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveComposerThinkingOptionId(input: {
|
||||||
|
selectedThinkingOptionId: string;
|
||||||
|
availableModels: AgentModelDefinition[];
|
||||||
|
effectiveModelId: string;
|
||||||
|
}): string {
|
||||||
|
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
|
||||||
|
if (selectedThinkingOptionId) {
|
||||||
|
return selectedThinkingOptionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedModelDefinition =
|
||||||
|
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
|
||||||
|
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDraftComposerCommandConfig(input: {
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
cwd: string;
|
||||||
|
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||||
|
selectedMode: string;
|
||||||
|
effectiveModelId: string;
|
||||||
|
effectiveThinkingOptionId: string;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
|
}): DraftCommandConfig | undefined {
|
||||||
|
const cwd = input.cwd.trim();
|
||||||
|
if (!input.provider || !cwd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: input.provider,
|
||||||
|
cwd,
|
||||||
|
...(input.modeOptions.length > 0 && input.selectedMode !== ""
|
||||||
|
? { modeId: input.selectedMode }
|
||||||
|
: {}),
|
||||||
|
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||||
|
...(input.effectiveThinkingOptionId
|
||||||
|
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||||
|
: {}),
|
||||||
|
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDraftStatusControls(input: {
|
||||||
|
formState: UseAgentFormStateResult;
|
||||||
|
features?: DraftAgentStatusBarProps["features"];
|
||||||
|
onSetFeature?: DraftAgentStatusBarProps["onSetFeature"];
|
||||||
|
onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"];
|
||||||
|
}): DraftAgentStatusBarProps {
|
||||||
|
const { formState, features, onSetFeature, onDropdownClose } = input;
|
||||||
|
return {
|
||||||
|
providerDefinitions: formState.providerDefinitions,
|
||||||
|
selectedProvider: formState.selectedProvider,
|
||||||
|
onSelectProvider: formState.setProviderFromUser,
|
||||||
|
modeOptions: formState.modeOptions,
|
||||||
|
selectedMode: formState.selectedMode,
|
||||||
|
onSelectMode: formState.setModeFromUser,
|
||||||
|
models: formState.availableModels,
|
||||||
|
selectedModel: formState.selectedModel,
|
||||||
|
onSelectModel: formState.setModelFromUser,
|
||||||
|
isModelLoading: formState.isModelLoading,
|
||||||
|
allProviderModels: formState.allProviderModels,
|
||||||
|
isAllModelsLoading: formState.isAllModelsLoading,
|
||||||
|
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
|
||||||
|
thinkingOptions: formState.availableThinkingOptions,
|
||||||
|
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||||
|
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||||
|
features,
|
||||||
|
onSetFeature,
|
||||||
|
onDropdownClose,
|
||||||
|
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDraftContent(input: {
|
||||||
|
text: string;
|
||||||
|
attachments: UserComposerAttachment[];
|
||||||
|
cwd: string;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areAttachmentsEqual(input: {
|
||||||
|
left: UserComposerAttachment[];
|
||||||
|
right: UserComposerAttachment[];
|
||||||
|
}): boolean {
|
||||||
|
if (input.left.length !== input.right.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.left.every((attachment, index) => {
|
||||||
|
const other = input.right[index];
|
||||||
|
return JSON.stringify(attachment) === JSON.stringify(other);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,194 +1,188 @@
|
|||||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildDraftComposerCommandConfig,
|
||||||
|
createAgentInputDraftCore,
|
||||||
|
resolveDraftKey,
|
||||||
|
resolveEffectiveComposerModelId,
|
||||||
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
|
} from "./use-agent-input-draft-core";
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
function makeStorage() {
|
||||||
default: {
|
const map = new Map<string, string>();
|
||||||
getItem: async () => null,
|
return {
|
||||||
setItem: async () => undefined,
|
getItem: async (key: string) => map.get(key) ?? null,
|
||||||
removeItem: async () => undefined,
|
setItem: async (key: string, value: string) => {
|
||||||
},
|
map.set(key, value);
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/attachments/service", () => ({
|
|
||||||
garbageCollectAttachments: async () => undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./use-agent-form-state", () => ({
|
|
||||||
useAgentFormState: () => ({
|
|
||||||
selectedServerId: "host-1",
|
|
||||||
setSelectedServerId: () => undefined,
|
|
||||||
setSelectedServerIdFromUser: () => undefined,
|
|
||||||
selectedProvider: "codex",
|
|
||||||
setProviderFromUser: () => undefined,
|
|
||||||
selectedMode: "auto",
|
|
||||||
setModeFromUser: () => undefined,
|
|
||||||
selectedModel: "",
|
|
||||||
setModelFromUser: () => undefined,
|
|
||||||
selectedThinkingOptionId: "",
|
|
||||||
setThinkingOptionFromUser: () => undefined,
|
|
||||||
workingDir: "/repo",
|
|
||||||
setWorkingDir: () => undefined,
|
|
||||||
setWorkingDirFromUser: () => undefined,
|
|
||||||
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
|
|
||||||
providerDefinitionMap: new Map(),
|
|
||||||
agentDefinition: undefined,
|
|
||||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
|
||||||
availableModels: [],
|
|
||||||
allProviderModels: new Map(),
|
|
||||||
isAllModelsLoading: false,
|
|
||||||
availableThinkingOptions: [],
|
|
||||||
isModelLoading: false,
|
|
||||||
modelError: null,
|
|
||||||
refreshProviderModels: () => undefined,
|
|
||||||
setProviderAndModelFromUser: () => undefined,
|
|
||||||
workingDirIsEmpty: false,
|
|
||||||
persistFormPreferences: async () => undefined,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let __private__: typeof import("./use-agent-input-draft").__private__;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const storage = new Map<string, string>();
|
|
||||||
Object.defineProperty(globalThis, "window", {
|
|
||||||
value: {
|
|
||||||
localStorage: {
|
|
||||||
getItem: (key: string) => storage.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => {
|
|
||||||
storage.set(key, value);
|
|
||||||
},
|
|
||||||
removeItem: (key: string) => {
|
|
||||||
storage.delete(key);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
configurable: true,
|
removeItem: async (key: string) => {
|
||||||
|
map.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveDraftKey", () => {
|
||||||
|
it("returns a string draft key unchanged", () => {
|
||||||
|
expect(
|
||||||
|
resolveDraftKey({
|
||||||
|
draftKey: "draft:key",
|
||||||
|
selectedServerId: "host-1",
|
||||||
|
}),
|
||||||
|
).toBe("draft:key");
|
||||||
});
|
});
|
||||||
|
|
||||||
({ __private__ } = await import("./use-agent-input-draft"));
|
it("resolves a computed draft key from the selected server", () => {
|
||||||
|
expect(
|
||||||
|
resolveDraftKey({
|
||||||
|
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
|
||||||
|
selectedServerId: "host-1",
|
||||||
|
}),
|
||||||
|
).toBe("draft:host-1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useAgentInputDraft", () => {
|
describe("resolveEffectiveComposerModelId", () => {
|
||||||
describe("__private__.resolveDraftKey", () => {
|
it("returns the selected model trimmed", () => {
|
||||||
it("returns an object draft key string unchanged", () => {
|
expect(
|
||||||
expect(
|
resolveEffectiveComposerModelId({
|
||||||
__private__.resolveDraftKey({
|
selectedModel: " gpt-5.4-mini ",
|
||||||
draftKey: "draft:key",
|
availableModels: [],
|
||||||
selectedServerId: "host-1",
|
}),
|
||||||
}),
|
).toBe("gpt-5.4-mini");
|
||||||
).toBe("draft:key");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves a computed draft key from the selected server", () => {
|
|
||||||
expect(
|
|
||||||
__private__.resolveDraftKey({
|
|
||||||
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
|
|
||||||
selectedServerId: "host-1",
|
|
||||||
}),
|
|
||||||
).toBe("draft:host-1");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("__private__.resolveEffectiveComposerModelId", () => {
|
it("returns empty string when no model selected", () => {
|
||||||
const models = [
|
expect(
|
||||||
{
|
resolveEffectiveComposerModelId({
|
||||||
provider: "codex",
|
selectedModel: "",
|
||||||
id: "gpt-5.4",
|
availableModels: [],
|
||||||
label: "gpt-5.4",
|
}),
|
||||||
isDefault: true,
|
).toBe("");
|
||||||
},
|
});
|
||||||
{
|
});
|
||||||
provider: "codex",
|
|
||||||
id: "gpt-5.4-mini",
|
|
||||||
label: "gpt-5.4-mini",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("prefers the selected model when present", () => {
|
describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||||
expect(
|
const models = [
|
||||||
__private__.resolveEffectiveComposerModelId({
|
{
|
||||||
selectedModel: "gpt-5.4-mini",
|
provider: "codex",
|
||||||
availableModels: models,
|
id: "gpt-5.4",
|
||||||
}),
|
label: "gpt-5.4",
|
||||||
).toBe("gpt-5.4-mini");
|
isDefault: true,
|
||||||
});
|
defaultThinkingOptionId: "high",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "medium", label: "Medium" },
|
||||||
|
{ id: "high", label: "High", isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
it("returns empty string when no model selected", () => {
|
it("prefers the selected thinking option when present", () => {
|
||||||
expect(
|
expect(
|
||||||
__private__.resolveEffectiveComposerModelId({
|
resolveEffectiveComposerThinkingOptionId({
|
||||||
selectedModel: "",
|
selectedThinkingOptionId: "medium",
|
||||||
availableModels: models,
|
availableModels: models,
|
||||||
}),
|
effectiveModelId: "gpt-5.4",
|
||||||
).toBe("");
|
}),
|
||||||
});
|
).toBe("medium");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
|
it("falls back to the model default thinking option", () => {
|
||||||
const models = [
|
expect(
|
||||||
{
|
resolveEffectiveComposerThinkingOptionId({
|
||||||
|
selectedThinkingOptionId: "",
|
||||||
|
availableModels: models,
|
||||||
|
effectiveModelId: "gpt-5.4",
|
||||||
|
}),
|
||||||
|
).toBe("high");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDraftComposerCommandConfig", () => {
|
||||||
|
it("returns undefined when cwd is empty", () => {
|
||||||
|
expect(
|
||||||
|
buildDraftComposerCommandConfig({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
id: "gpt-5.4",
|
cwd: " ",
|
||||||
label: "gpt-5.4",
|
modeOptions: [],
|
||||||
isDefault: true,
|
selectedMode: "",
|
||||||
defaultThinkingOptionId: "high",
|
effectiveModelId: "gpt-5.4",
|
||||||
thinkingOptions: [
|
effectiveThinkingOptionId: "high",
|
||||||
{ id: "medium", label: "Medium" },
|
}),
|
||||||
{ id: "high", label: "High", isDefault: true },
|
).toBeUndefined();
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("prefers the selected thinking option when present", () => {
|
|
||||||
expect(
|
|
||||||
__private__.resolveEffectiveComposerThinkingOptionId({
|
|
||||||
selectedThinkingOptionId: "medium",
|
|
||||||
availableModels: models,
|
|
||||||
effectiveModelId: "gpt-5.4",
|
|
||||||
}),
|
|
||||||
).toBe("medium");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the model default thinking option", () => {
|
|
||||||
expect(
|
|
||||||
__private__.resolveEffectiveComposerThinkingOptionId({
|
|
||||||
selectedThinkingOptionId: "",
|
|
||||||
availableModels: models,
|
|
||||||
effectiveModelId: "gpt-5.4",
|
|
||||||
}),
|
|
||||||
).toBe("high");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("__private__.buildDraftComposerCommandConfig", () => {
|
it("builds the draft command config from derived composer state", () => {
|
||||||
it("returns undefined when cwd is empty", () => {
|
expect(
|
||||||
expect(
|
buildDraftComposerCommandConfig({
|
||||||
__private__.buildDraftComposerCommandConfig({
|
|
||||||
provider: "codex",
|
|
||||||
cwd: " ",
|
|
||||||
modeOptions: [],
|
|
||||||
selectedMode: "",
|
|
||||||
effectiveModelId: "gpt-5.4",
|
|
||||||
effectiveThinkingOptionId: "high",
|
|
||||||
}),
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds the draft command config from derived composer state", () => {
|
|
||||||
expect(
|
|
||||||
__private__.buildDraftComposerCommandConfig({
|
|
||||||
provider: "codex",
|
|
||||||
cwd: "/repo",
|
|
||||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
|
||||||
selectedMode: "auto",
|
|
||||||
effectiveModelId: "gpt-5.4",
|
|
||||||
effectiveThinkingOptionId: "high",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
modeId: "auto",
|
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||||
model: "gpt-5.4",
|
selectedMode: "auto",
|
||||||
thinkingOptionId: "high",
|
effectiveModelId: "gpt-5.4",
|
||||||
});
|
effectiveThinkingOptionId: "high",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/repo",
|
||||||
|
modeId: "auto",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
thinkingOptionId: "high",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createAgentInputDraftCore", () => {
|
||||||
|
it("load returns null when nothing has been saved", async () => {
|
||||||
|
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||||
|
expect(await core.load()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("load returns the draft that was saved", async () => {
|
||||||
|
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||||
|
await core.save({ text: "hello world", attachments: [], cwd: "/repo" });
|
||||||
|
expect(await core.load()).toEqual({ text: "hello world", attachments: [], cwd: "/repo" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear removes the persisted draft", async () => {
|
||||||
|
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||||
|
await core.save({ text: "hello", attachments: [], cwd: "/repo" });
|
||||||
|
await core.clear();
|
||||||
|
expect(await core.load()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("load seeds cwd from initialCwd when cwd is absent from stored data", async () => {
|
||||||
|
const storage = makeStorage();
|
||||||
|
await storage.setItem("test", JSON.stringify({ text: "hello", attachments: [] }));
|
||||||
|
|
||||||
|
const core = createAgentInputDraftCore({ storage, storageKey: "test" });
|
||||||
|
const draft = await core.load("/initial");
|
||||||
|
expect(draft?.cwd).toBe("/initial");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips attachments unchanged", async () => {
|
||||||
|
const attachment = {
|
||||||
|
kind: "github_issue" as const,
|
||||||
|
item: {
|
||||||
|
kind: "issue" as const,
|
||||||
|
number: 42,
|
||||||
|
title: "Unify attachments",
|
||||||
|
url: "https://github.com/example/repo/issues/42",
|
||||||
|
state: "open" as const,
|
||||||
|
body: "body",
|
||||||
|
labels: ["composer"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
|
||||||
|
await core.save({ text: "", attachments: [attachment], cwd: "/repo" });
|
||||||
|
const draft = await core.load();
|
||||||
|
expect(draft?.attachments).toEqual([attachment]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolated keys do not share state", async () => {
|
||||||
|
const storage = makeStorage();
|
||||||
|
const a = createAgentInputDraftCore({ storage, storageKey: "key-a" });
|
||||||
|
const b = createAgentInputDraftCore({ storage, storageKey: "key-b" });
|
||||||
|
await a.save({ text: "from a", attachments: [], cwd: "" });
|
||||||
|
expect(await b.load()).toBeNull();
|
||||||
|
expect(await a.load()).toMatchObject({ text: "from a" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ import {
|
|||||||
type UseAgentFormStateResult,
|
type UseAgentFormStateResult,
|
||||||
} from "@/hooks/use-agent-form-state";
|
} from "@/hooks/use-agent-form-state";
|
||||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||||
|
import {
|
||||||
|
areAttachmentsEqual,
|
||||||
|
buildDraftComposerCommandConfig,
|
||||||
|
buildDraftStatusControls,
|
||||||
|
hasDraftContent,
|
||||||
|
resolveDraftKey,
|
||||||
|
resolveEffectiveComposerModelId,
|
||||||
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
|
type DraftKeyInput,
|
||||||
|
} from "@/hooks/use-agent-input-draft-core";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
type AttachmentUpdater =
|
type AttachmentUpdater =
|
||||||
| UserComposerAttachment[]
|
| UserComposerAttachment[]
|
||||||
@@ -24,12 +32,6 @@ interface AgentInputDraftComposerOptions {
|
|||||||
lockedWorkingDir?: string;
|
lockedWorkingDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DraftKeyContext {
|
|
||||||
selectedServerId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
|
|
||||||
|
|
||||||
interface UseAgentInputDraftInput {
|
interface UseAgentInputDraftInput {
|
||||||
draftKey: DraftKeyInput;
|
draftKey: DraftKeyInput;
|
||||||
initialCwd?: string;
|
initialCwd?: string;
|
||||||
@@ -57,121 +59,6 @@ interface AgentInputDraft {
|
|||||||
composerState: DraftComposerState | null;
|
composerState: DraftComposerState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasDraftContent(input: {
|
|
||||||
text: string;
|
|
||||||
attachments: UserComposerAttachment[];
|
|
||||||
cwd: string;
|
|
||||||
}): boolean {
|
|
||||||
return (
|
|
||||||
input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function areAttachmentsEqual(input: {
|
|
||||||
left: UserComposerAttachment[];
|
|
||||||
right: UserComposerAttachment[];
|
|
||||||
}): boolean {
|
|
||||||
if (input.left.length !== input.right.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return input.left.every((attachment, index) => {
|
|
||||||
const other = input.right[index];
|
|
||||||
return JSON.stringify(attachment) === JSON.stringify(other);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDraftKey(input: {
|
|
||||||
draftKey: DraftKeyInput;
|
|
||||||
selectedServerId: string | null;
|
|
||||||
}): string {
|
|
||||||
if (typeof input.draftKey === "function") {
|
|
||||||
return input.draftKey({ selectedServerId: input.selectedServerId });
|
|
||||||
}
|
|
||||||
return input.draftKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveEffectiveComposerModelId(input: {
|
|
||||||
selectedModel: string;
|
|
||||||
availableModels: AgentModelDefinition[];
|
|
||||||
}): string {
|
|
||||||
return input.selectedModel.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveEffectiveComposerThinkingOptionId(input: {
|
|
||||||
selectedThinkingOptionId: string;
|
|
||||||
availableModels: AgentModelDefinition[];
|
|
||||||
effectiveModelId: string;
|
|
||||||
}): string {
|
|
||||||
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
|
|
||||||
if (selectedThinkingOptionId) {
|
|
||||||
return selectedThinkingOptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedModelDefinition =
|
|
||||||
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
|
|
||||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDraftComposerCommandConfig(input: {
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
cwd: string;
|
|
||||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
|
||||||
selectedMode: string;
|
|
||||||
effectiveModelId: string;
|
|
||||||
effectiveThinkingOptionId: string;
|
|
||||||
featureValues?: Record<string, unknown>;
|
|
||||||
}): DraftCommandConfig | undefined {
|
|
||||||
const cwd = input.cwd.trim();
|
|
||||||
if (!input.provider || !cwd) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: input.provider,
|
|
||||||
cwd,
|
|
||||||
...(input.modeOptions.length > 0 && input.selectedMode !== ""
|
|
||||||
? { modeId: input.selectedMode }
|
|
||||||
: {}),
|
|
||||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
|
||||||
...(input.effectiveThinkingOptionId
|
|
||||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
|
||||||
: {}),
|
|
||||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDraftStatusControls(input: {
|
|
||||||
formState: UseAgentFormStateResult;
|
|
||||||
features?: DraftAgentStatusBarProps["features"];
|
|
||||||
onSetFeature?: DraftAgentStatusBarProps["onSetFeature"];
|
|
||||||
onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"];
|
|
||||||
}): DraftAgentStatusBarProps {
|
|
||||||
const { formState, features, onSetFeature, onDropdownClose } = input;
|
|
||||||
return {
|
|
||||||
providerDefinitions: formState.providerDefinitions,
|
|
||||||
selectedProvider: formState.selectedProvider,
|
|
||||||
onSelectProvider: formState.setProviderFromUser,
|
|
||||||
modeOptions: formState.modeOptions,
|
|
||||||
selectedMode: formState.selectedMode,
|
|
||||||
onSelectMode: formState.setModeFromUser,
|
|
||||||
models: formState.availableModels,
|
|
||||||
selectedModel: formState.selectedModel,
|
|
||||||
onSelectModel: formState.setModelFromUser,
|
|
||||||
isModelLoading: formState.isModelLoading,
|
|
||||||
allProviderModels: formState.allProviderModels,
|
|
||||||
isAllModelsLoading: formState.isAllModelsLoading,
|
|
||||||
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
|
|
||||||
thinkingOptions: formState.availableThinkingOptions,
|
|
||||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
|
||||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
|
||||||
features,
|
|
||||||
onSetFeature,
|
|
||||||
onDropdownClose,
|
|
||||||
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
||||||
const composerOptions = input.composer ?? null;
|
const composerOptions = input.composer ?? null;
|
||||||
const formState = useAgentFormState({
|
const formState = useAgentFormState({
|
||||||
|
|||||||
@@ -1,191 +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";
|
|
||||||
|
|
||||||
const { navigateToWorkspaceMock, routerMock, pathState } = vi.hoisted(() => ({
|
|
||||||
navigateToWorkspaceMock: vi.fn(),
|
|
||||||
routerMock: {
|
|
||||||
back: vi.fn(),
|
|
||||||
push: vi.fn(),
|
|
||||||
replace: vi.fn(),
|
|
||||||
},
|
|
||||||
pathState: {
|
|
||||||
pathname: "/h/srv/workspace/ws-2",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.hoisted(() => {
|
|
||||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("expo-router", () => ({
|
|
||||||
usePathname: () => pathState.pathname,
|
|
||||||
useRouter: () => routerMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/layout", () => ({
|
|
||||||
getIsElectronRuntime: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
|
||||||
isNative: false,
|
|
||||||
isWeb: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/shortcut-platform", () => ({
|
|
||||||
getShortcutOs: () => "mac",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-active-server-id", () => ({
|
|
||||||
useActiveServerId: () => "srv",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-open-project-picker", () => ({
|
|
||||||
useOpenProjectPicker: () => vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-keyboard-shortcut-overrides", () => ({
|
|
||||||
useKeyboardShortcutOverrides: () => ({ overrides: {} }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-workspace-navigation", () => ({
|
|
||||||
navigateToWorkspace: navigateToWorkspaceMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { useKeyboardShortcuts } from "./use-keyboard-shortcuts";
|
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
|
||||||
import {
|
|
||||||
activateNavigationWorkspaceSelection,
|
|
||||||
syncNavigationActiveWorkspace,
|
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
|
||||||
|
|
||||||
function Probe() {
|
|
||||||
useKeyboardShortcuts({
|
|
||||||
enabled: true,
|
|
||||||
isMobile: false,
|
|
||||||
toggleAgentList: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("useKeyboardShortcuts", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
navigateToWorkspaceMock.mockReset();
|
|
||||||
routerMock.back.mockReset();
|
|
||||||
routerMock.push.mockReset();
|
|
||||||
routerMock.replace.mockReset();
|
|
||||||
pathState.pathname = "/h/srv/workspace/ws-2";
|
|
||||||
syncNavigationActiveWorkspace({ current: null });
|
|
||||||
useKeyboardShortcutsStore.setState({
|
|
||||||
capturingShortcut: false,
|
|
||||||
commandCenterOpen: false,
|
|
||||||
sidebarShortcutWorkspaceTargets: [
|
|
||||||
{ serverId: "srv", workspaceId: "ws-1" },
|
|
||||||
{ serverId: "srv", workspaceId: "ws-2" },
|
|
||||||
{ serverId: "srv", workspaceId: "ws-3" },
|
|
||||||
{ serverId: "srv", workspaceId: "ws-4" },
|
|
||||||
{ serverId: "srv", workspaceId: "ws-5" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
syncNavigationActiveWorkspace({ current: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the retained active workspace instead of stale pathname for bracket navigation", async () => {
|
|
||||||
activateNavigationWorkspaceSelection({ serverId: "srv", workspaceId: "ws-4" });
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<Probe />);
|
|
||||||
});
|
|
||||||
|
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: "]",
|
|
||||||
code: "BracketRight",
|
|
||||||
metaKey: true,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
|
|
||||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("srv", "ws-5", {
|
|
||||||
currentPathname: "/h/srv/workspace/ws-2",
|
|
||||||
});
|
|
||||||
expect(event.defaultPrevented).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dispatches Escape as an agent interrupt action", async () => {
|
|
||||||
const handleInterrupt = vi.fn(() => true);
|
|
||||||
const unregister = keyboardActionDispatcher.registerHandler({
|
|
||||||
handlerId: "test-agent-interrupt",
|
|
||||||
actions: ["agent.interrupt"],
|
|
||||||
enabled: true,
|
|
||||||
priority: 100,
|
|
||||||
isActive: () => true,
|
|
||||||
handle: handleInterrupt,
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<Probe />);
|
|
||||||
});
|
|
||||||
|
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: "Escape",
|
|
||||||
code: "Escape",
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
|
|
||||||
expect(handleInterrupt).toHaveBeenCalledTimes(1);
|
|
||||||
unregister();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns from desktop settings to the retained workspace without browser history back", async () => {
|
|
||||||
syncNavigationActiveWorkspace({
|
|
||||||
current: {
|
|
||||||
getCurrentRoute: () => ({ path: "/h/srv/workspace/ws-2" }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
pathState.pathname = "/settings/general";
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<Probe />);
|
|
||||||
});
|
|
||||||
|
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: ",",
|
|
||||||
code: "Comma",
|
|
||||||
metaKey: true,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
});
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
|
|
||||||
expect(routerMock.replace).toHaveBeenCalledWith("/h/srv/workspace/ws-2");
|
|
||||||
expect(routerMock.back).not.toHaveBeenCalled();
|
|
||||||
expect(event.defaultPrevented).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,16 +3,7 @@ import { usePathname, useRouter } from "expo-router";
|
|||||||
import { getIsElectronRuntime } from "@/constants/layout";
|
import { getIsElectronRuntime } from "@/constants/layout";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
import {
|
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
buildSettingsRoute,
|
|
||||||
parseHostWorkspaceRouteFromPathname,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||||
import {
|
|
||||||
type MessageInputKeyboardActionKind,
|
|
||||||
type KeyboardShortcutPayload,
|
|
||||||
} from "@/keyboard/actions";
|
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import {
|
import {
|
||||||
type ChordState,
|
type ChordState,
|
||||||
@@ -20,26 +11,23 @@ import {
|
|||||||
buildEffectiveBindings,
|
buildEffectiveBindings,
|
||||||
} from "@/keyboard/keyboard-shortcuts";
|
} from "@/keyboard/keyboard-shortcuts";
|
||||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||||
|
import {
|
||||||
|
routeKeyboardShortcut,
|
||||||
|
type ShortcutAction,
|
||||||
|
type ShortcutCallbackName,
|
||||||
|
} from "@/keyboard/route-shortcut";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
|
|
||||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||||
import {
|
import {
|
||||||
getLastNavigationWorkspaceRouteSelection,
|
getLastNavigationWorkspaceRouteSelection,
|
||||||
getNavigationActiveWorkspaceSelection,
|
getNavigationActiveWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
function hasPayloadKey<K extends string>(
|
|
||||||
payload: KeyboardShortcutPayload,
|
|
||||||
key: K,
|
|
||||||
): payload is KeyboardShortcutPayload & Record<K, never> {
|
|
||||||
return !!payload && typeof payload === "object" && key in payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboardShortcuts({
|
export function useKeyboardShortcuts({
|
||||||
enabled,
|
enabled,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -82,241 +70,57 @@ export function useKeyboardShortcuts({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToWorkspaceShortcut = (index: number): boolean => {
|
const captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
|
||||||
const state = useKeyboardShortcutsStore.getState();
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
const target = state.sidebarShortcutWorkspaceTargets[index - 1] ?? null;
|
const targetEl =
|
||||||
if (!target) {
|
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||||
return false;
|
(target instanceof HTMLElement ? target : null);
|
||||||
}
|
const active = document.activeElement;
|
||||||
|
const activeEl = active instanceof HTMLElement ? active : null;
|
||||||
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
|
||||||
return true;
|
|
||||||
};
|
|
||||||
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
|
|
||||||
const state = useKeyboardShortcutsStore.getState();
|
|
||||||
const targets = state.sidebarShortcutWorkspaceTargets;
|
|
||||||
if (targets.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceRoute =
|
|
||||||
getNavigationActiveWorkspaceSelection() ?? parseHostWorkspaceRouteFromPathname(pathname);
|
|
||||||
const target = getRelativeSidebarShortcutTarget({
|
|
||||||
targets,
|
|
||||||
currentTarget: workspaceRoute
|
|
||||||
? {
|
|
||||||
serverId: workspaceRoute.serverId,
|
|
||||||
workspaceId: workspaceRoute.workspaceId,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
delta,
|
|
||||||
});
|
|
||||||
if (!target) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openProjectPicker = (): boolean => {
|
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
|
||||||
void openProjectPickerAction();
|
"toggle-agent-list": toggleAgentList,
|
||||||
return true;
|
"toggle-both-sidebars": toggleBothSidebars,
|
||||||
|
"toggle-focus-mode": toggleFocusMode,
|
||||||
|
"cycle-theme": cycleTheme,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dispatchMessageInputAction = (kind: MessageInputKeyboardActionKind): boolean => {
|
const performShortcutAction = (action: ShortcutAction, event: KeyboardEvent): boolean => {
|
||||||
switch (kind) {
|
switch (action.kind) {
|
||||||
case "focus":
|
case "none":
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "message-input.focus",
|
|
||||||
scope: "message-input",
|
|
||||||
});
|
|
||||||
case "send":
|
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "message-input.send",
|
|
||||||
scope: "message-input",
|
|
||||||
});
|
|
||||||
case "dictation-toggle":
|
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "message-input.dictation-toggle",
|
|
||||||
scope: "message-input",
|
|
||||||
});
|
|
||||||
case "dictation-cancel":
|
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "message-input.dictation-cancel",
|
|
||||||
scope: "message-input",
|
|
||||||
});
|
|
||||||
case "dictation-confirm":
|
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "message-input.dictation-confirm",
|
|
||||||
scope: "message-input",
|
|
||||||
});
|
|
||||||
case "voice-toggle":
|
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "message-input.voice-toggle",
|
|
||||||
scope: "message-input",
|
|
||||||
});
|
|
||||||
case "voice-mute-toggle":
|
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "message-input.voice-mute-toggle",
|
|
||||||
scope: "message-input",
|
|
||||||
});
|
|
||||||
default:
|
|
||||||
return false;
|
return false;
|
||||||
}
|
case "dispatch":
|
||||||
};
|
return keyboardActionDispatcher.dispatch(action.action);
|
||||||
const handleDispatchOnlyAction = (action: string): boolean | null => {
|
case "navigate-workspace":
|
||||||
switch (action) {
|
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
||||||
case "agent.interrupt":
|
|
||||||
return keyboardActionDispatcher.dispatch({ id: "agent.interrupt", scope: "global" });
|
|
||||||
case "workspace.tab.new":
|
|
||||||
return keyboardActionDispatcher.dispatch({ id: "workspace.tab.new", scope: "workspace" });
|
|
||||||
case "worktree.archive":
|
|
||||||
return keyboardActionDispatcher.dispatch({ id: "worktree.archive", scope: "sidebar" });
|
|
||||||
case "worktree.new":
|
|
||||||
return keyboardActionDispatcher.dispatch({ id: "worktree.new", scope: "sidebar" });
|
|
||||||
case "workspace.terminal.new":
|
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "workspace.terminal.new",
|
|
||||||
scope: "workspace",
|
|
||||||
});
|
|
||||||
case "workspace.tab.close.current":
|
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "workspace.tab.close-current",
|
|
||||||
scope: "workspace",
|
|
||||||
});
|
|
||||||
case "sidebar.toggle.right":
|
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "sidebar.toggle.right",
|
|
||||||
scope: "sidebar",
|
|
||||||
});
|
|
||||||
case "workspace.pane.split.right":
|
|
||||||
case "workspace.pane.split.down":
|
|
||||||
case "workspace.pane.focus.left":
|
|
||||||
case "workspace.pane.focus.right":
|
|
||||||
case "workspace.pane.focus.up":
|
|
||||||
case "workspace.pane.focus.down":
|
|
||||||
case "workspace.pane.move-tab.left":
|
|
||||||
case "workspace.pane.move-tab.right":
|
|
||||||
case "workspace.pane.move-tab.up":
|
|
||||||
case "workspace.pane.move-tab.down":
|
|
||||||
case "workspace.pane.close":
|
|
||||||
return keyboardActionDispatcher.dispatch({ id: action, scope: "workspace" });
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePayloadAction = (
|
|
||||||
action: string,
|
|
||||||
payload: KeyboardShortcutPayload,
|
|
||||||
): boolean | null => {
|
|
||||||
switch (action) {
|
|
||||||
case "workspace.tab.navigate.index":
|
|
||||||
if (!hasPayloadKey(payload, "index")) return false;
|
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "workspace.tab.navigate-index",
|
|
||||||
scope: "workspace",
|
|
||||||
index: payload.index,
|
|
||||||
});
|
|
||||||
case "workspace.tab.navigate.relative":
|
|
||||||
if (!hasPayloadKey(payload, "delta")) return false;
|
|
||||||
return keyboardActionDispatcher.dispatch({
|
|
||||||
id: "workspace.tab.navigate-relative",
|
|
||||||
scope: "workspace",
|
|
||||||
delta: payload.delta,
|
|
||||||
});
|
|
||||||
case "workspace.navigate.index":
|
|
||||||
if (!hasPayloadKey(payload, "index")) return false;
|
|
||||||
return navigateToWorkspaceShortcut(payload.index);
|
|
||||||
case "workspace.navigate.relative":
|
|
||||||
if (!hasPayloadKey(payload, "delta")) return false;
|
|
||||||
return navigateRelativeWorkspace(payload.delta);
|
|
||||||
case "message-input.action":
|
|
||||||
if (!hasPayloadKey(payload, "kind")) return false;
|
|
||||||
return dispatchMessageInputAction(payload.kind);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingsToggle = (): boolean => {
|
|
||||||
if (pathname.startsWith("/settings")) {
|
|
||||||
if (!isMobile) {
|
|
||||||
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
|
|
||||||
if (lastWorkspaceRoute) {
|
|
||||||
router.replace(
|
|
||||||
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
router.back();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
router.push(buildSettingsRoute());
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommandCenterToggle = (event: KeyboardEvent): boolean => {
|
|
||||||
const store = useKeyboardShortcutsStore.getState();
|
|
||||||
if (!store.commandCenterOpen) {
|
|
||||||
const target = event.target instanceof Element ? event.target : null;
|
|
||||||
const targetEl =
|
|
||||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
|
||||||
(target instanceof HTMLElement ? target : null);
|
|
||||||
const active = document.activeElement;
|
|
||||||
const activeEl = active instanceof HTMLElement ? active : null;
|
|
||||||
setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null);
|
|
||||||
}
|
|
||||||
store.setCommandCenterOpen(!store.commandCenterOpen);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAction = (input: {
|
|
||||||
action: string;
|
|
||||||
payload: KeyboardShortcutPayload;
|
|
||||||
event: KeyboardEvent;
|
|
||||||
}): boolean => {
|
|
||||||
const dispatchOnlyResult = handleDispatchOnlyAction(input.action);
|
|
||||||
if (dispatchOnlyResult !== null) {
|
|
||||||
return dispatchOnlyResult;
|
|
||||||
}
|
|
||||||
const payloadResult = handlePayloadAction(input.action, input.payload);
|
|
||||||
if (payloadResult !== null) {
|
|
||||||
return payloadResult;
|
|
||||||
}
|
|
||||||
switch (input.action) {
|
|
||||||
case "agent.new":
|
|
||||||
return openProjectPicker();
|
|
||||||
case "sidebar.toggle.left":
|
|
||||||
toggleAgentList();
|
|
||||||
return true;
|
return true;
|
||||||
case "settings.toggle":
|
case "router-replace":
|
||||||
return handleSettingsToggle();
|
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
||||||
case "sidebar.toggle.both":
|
|
||||||
if (toggleBothSidebars) {
|
|
||||||
toggleBothSidebars();
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
case "view.toggle.focus":
|
case "router-back":
|
||||||
if (toggleFocusMode) {
|
router.back();
|
||||||
toggleFocusMode();
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
case "theme.cycle":
|
case "router-push":
|
||||||
if (cycleTheme) {
|
router.push(action.route as Parameters<typeof router.push>[0]);
|
||||||
cycleTheme();
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
case "command-center.toggle":
|
case "open-project-picker":
|
||||||
return handleCommandCenterToggle(input.event);
|
void openProjectPickerAction();
|
||||||
case "shortcuts.dialog.toggle": {
|
return true;
|
||||||
const store = useKeyboardShortcutsStore.getState();
|
case "callback":
|
||||||
store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
|
callbacksByName[action.name]?.();
|
||||||
|
return true;
|
||||||
|
case "command-center-toggle": {
|
||||||
|
if (action.nextOpen) {
|
||||||
|
captureCommandCenterFocusRestore(event);
|
||||||
|
}
|
||||||
|
useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
default:
|
case "shortcuts-dialog-toggle":
|
||||||
return false;
|
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -384,11 +188,20 @@ export function useKeyboardShortcuts({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handled = handleAction({
|
const shortcutAction = routeKeyboardShortcut(
|
||||||
action: result.match.action,
|
{ action: result.match.action, payload: result.match.payload },
|
||||||
payload: result.match.payload,
|
{
|
||||||
event,
|
pathname,
|
||||||
});
|
isMobile,
|
||||||
|
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||||
|
navigationActiveWorkspace: getNavigationActiveWorkspaceSelection(),
|
||||||
|
lastNavigationWorkspaceRoute: getLastNavigationWorkspaceRouteSelection(),
|
||||||
|
commandCenterOpen: store.commandCenterOpen,
|
||||||
|
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handled = performShortcutAction(shortcutAction, event);
|
||||||
if (!handled) {
|
if (!handled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
routeKeyboardShortcut,
|
||||||
|
type ShortcutAction,
|
||||||
|
type ShortcutRoutingContext,
|
||||||
|
} from "./route-shortcut";
|
||||||
|
|
||||||
|
const SIDEBAR_TARGETS = [
|
||||||
|
{ serverId: "srv", workspaceId: "ws-1" },
|
||||||
|
{ serverId: "srv", workspaceId: "ws-2" },
|
||||||
|
{ serverId: "srv", workspaceId: "ws-3" },
|
||||||
|
{ serverId: "srv", workspaceId: "ws-4" },
|
||||||
|
{ serverId: "srv", workspaceId: "ws-5" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function makeCtx(overrides: Partial<ShortcutRoutingContext> = {}): ShortcutRoutingContext {
|
||||||
|
return {
|
||||||
|
pathname: "/h/srv/workspace/ws-2",
|
||||||
|
isMobile: false,
|
||||||
|
sidebarShortcutTargets: SIDEBAR_TARGETS,
|
||||||
|
navigationActiveWorkspace: null,
|
||||||
|
lastNavigationWorkspaceRoute: null,
|
||||||
|
commandCenterOpen: false,
|
||||||
|
shortcutsDialogOpen: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — dispatch passthroughs", () => {
|
||||||
|
it.each([
|
||||||
|
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
|
||||||
|
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
|
||||||
|
["worktree.archive", { id: "worktree.archive", scope: "sidebar" }],
|
||||||
|
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
|
||||||
|
["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }],
|
||||||
|
["workspace.tab.close.current", { id: "workspace.tab.close-current", scope: "workspace" }],
|
||||||
|
["sidebar.toggle.right", { id: "sidebar.toggle.right", scope: "sidebar" }],
|
||||||
|
["workspace.pane.split.right", { id: "workspace.pane.split.right", scope: "workspace" }],
|
||||||
|
["workspace.pane.split.down", { id: "workspace.pane.split.down", scope: "workspace" }],
|
||||||
|
["workspace.pane.focus.left", { id: "workspace.pane.focus.left", scope: "workspace" }],
|
||||||
|
["workspace.pane.focus.right", { id: "workspace.pane.focus.right", scope: "workspace" }],
|
||||||
|
["workspace.pane.focus.up", { id: "workspace.pane.focus.up", scope: "workspace" }],
|
||||||
|
["workspace.pane.focus.down", { id: "workspace.pane.focus.down", scope: "workspace" }],
|
||||||
|
["workspace.pane.move-tab.left", { id: "workspace.pane.move-tab.left", scope: "workspace" }],
|
||||||
|
["workspace.pane.move-tab.right", { id: "workspace.pane.move-tab.right", scope: "workspace" }],
|
||||||
|
["workspace.pane.move-tab.up", { id: "workspace.pane.move-tab.up", scope: "workspace" }],
|
||||||
|
["workspace.pane.move-tab.down", { id: "workspace.pane.move-tab.down", scope: "workspace" }],
|
||||||
|
["workspace.pane.close", { id: "workspace.pane.close", scope: "workspace" }],
|
||||||
|
])("%s → dispatch %j", (action, expected) => {
|
||||||
|
expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual({
|
||||||
|
kind: "dispatch",
|
||||||
|
action: expected,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — workspace.tab.navigate", () => {
|
||||||
|
it("forwards index payloads to the workspace.tab.navigate-index dispatch", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.tab.navigate.index", payload: { index: 3 } },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "dispatch",
|
||||||
|
action: { id: "workspace.tab.navigate-index", scope: "workspace", index: 3 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when index payload is missing", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "workspace.tab.navigate.index", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards delta payloads to the workspace.tab.navigate-relative dispatch", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.tab.navigate.relative", payload: { delta: -1 } },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "dispatch",
|
||||||
|
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when delta payload is missing", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.tab.navigate.relative", payload: null },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — workspace.navigate.index", () => {
|
||||||
|
it("navigates to the sidebar target at index-1", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.index", payload: { index: 4 } },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId: "ws-4",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when the target index is out of range", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.index", payload: { index: 99 } },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when the index payload is missing", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "workspace.navigate.index", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when there are no sidebar targets", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.index", payload: { index: 1 } },
|
||||||
|
makeCtx({ sidebarShortcutTargets: [] }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — workspace.navigate.relative", () => {
|
||||||
|
it("uses the retained navigation workspace selection over a stale pathname", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
|
||||||
|
makeCtx({
|
||||||
|
pathname: "/h/srv/workspace/ws-2",
|
||||||
|
navigationActiveWorkspace: { serverId: "srv", workspaceId: "ws-4" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId: "ws-5",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the pathname workspace when no retained selection exists", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.relative", payload: { delta: -1 } },
|
||||||
|
makeCtx({ pathname: "/h/srv/workspace/ws-3", navigationActiveWorkspace: null }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId: "ws-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps from the last target forward to the first", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
|
||||||
|
makeCtx({ navigationActiveWorkspace: { serverId: "srv", workspaceId: "ws-5" } }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when there are no sidebar targets", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
|
||||||
|
makeCtx({ sidebarShortcutTargets: [] }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when the delta payload is missing", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "workspace.navigate.relative", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first target when the current workspace is not in the sidebar", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
|
||||||
|
makeCtx({
|
||||||
|
pathname: "/settings/general",
|
||||||
|
navigationActiveWorkspace: { serverId: "other", workspaceId: "ws-x" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — message-input.action", () => {
|
||||||
|
it.each([
|
||||||
|
["focus", "message-input.focus"],
|
||||||
|
["send", "message-input.send"],
|
||||||
|
["dictation-toggle", "message-input.dictation-toggle"],
|
||||||
|
["dictation-cancel", "message-input.dictation-cancel"],
|
||||||
|
["dictation-confirm", "message-input.dictation-confirm"],
|
||||||
|
["voice-toggle", "message-input.voice-toggle"],
|
||||||
|
["voice-mute-toggle", "message-input.voice-mute-toggle"],
|
||||||
|
] as const)("kind=%s → dispatch %s", (kind, id) => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "message-input.action", payload: { kind } }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "dispatch",
|
||||||
|
action: { id, scope: "message-input" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none for unsupported message-input kinds (queue)", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "message-input.action", payload: { kind: "queue" } },
|
||||||
|
makeCtx(),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns none when kind is missing", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "message-input.action", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — settings.toggle", () => {
|
||||||
|
it("pushes to the settings root when not currently in settings", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "settings.toggle", payload: null },
|
||||||
|
makeCtx({ pathname: "/h/srv/workspace/ws-2" }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "router-push", route: "/settings" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces to the retained workspace route when leaving settings on desktop", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "settings.toggle", payload: null },
|
||||||
|
makeCtx({
|
||||||
|
pathname: "/settings/general",
|
||||||
|
isMobile: false,
|
||||||
|
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({
|
||||||
|
kind: "router-replace",
|
||||||
|
route: "/h/srv/workspace/ws-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to router.back() when no retained workspace exists", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "settings.toggle", payload: null },
|
||||||
|
makeCtx({ pathname: "/settings/general", lastNavigationWorkspaceRoute: null }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "router-back" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to router.back() on mobile even if a retained workspace exists", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "settings.toggle", payload: null },
|
||||||
|
makeCtx({
|
||||||
|
pathname: "/settings/general",
|
||||||
|
isMobile: true,
|
||||||
|
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "router-back" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — callbacks and pickers", () => {
|
||||||
|
it.each([
|
||||||
|
["sidebar.toggle.left", "toggle-agent-list"],
|
||||||
|
["sidebar.toggle.both", "toggle-both-sidebars"],
|
||||||
|
["view.toggle.focus", "toggle-focus-mode"],
|
||||||
|
["theme.cycle", "cycle-theme"],
|
||||||
|
] as const)("%s → callback %s", (action, name) => {
|
||||||
|
expect(routeKeyboardShortcut({ action, payload: null }, makeCtx())).toEqual<ShortcutAction>({
|
||||||
|
kind: "callback",
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent.new → open-project-picker", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "agent.new", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "open-project-picker" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — toggle dialogs", () => {
|
||||||
|
it("opens the command center when closed", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "command-center.toggle", payload: null },
|
||||||
|
makeCtx({ commandCenterOpen: false }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "command-center-toggle", nextOpen: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the command center when open", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "command-center.toggle", payload: null },
|
||||||
|
makeCtx({ commandCenterOpen: true }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "command-center-toggle", nextOpen: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the shortcuts dialog", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "shortcuts.dialog.toggle", payload: null },
|
||||||
|
makeCtx({ shortcutsDialogOpen: false }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "shortcuts-dialog-toggle", nextOpen: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut(
|
||||||
|
{ action: "shortcuts.dialog.toggle", payload: null },
|
||||||
|
makeCtx({ shortcutsDialogOpen: true }),
|
||||||
|
),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "shortcuts-dialog-toggle", nextOpen: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("routeKeyboardShortcut — unknown actions", () => {
|
||||||
|
it("returns none for unknown action ids", () => {
|
||||||
|
expect(
|
||||||
|
routeKeyboardShortcut({ action: "totally.made.up", payload: null }, makeCtx()),
|
||||||
|
).toEqual<ShortcutAction>({ kind: "none" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import type { KeyboardShortcutPayload, MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import {
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
|
buildSettingsRoute,
|
||||||
|
parseHostWorkspaceRouteFromPathname,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
|
import {
|
||||||
|
getRelativeSidebarShortcutTarget,
|
||||||
|
type SidebarShortcutWorkspaceTarget,
|
||||||
|
} from "@/utils/sidebar-shortcuts";
|
||||||
|
|
||||||
|
export interface ShortcutRoutingContext {
|
||||||
|
pathname: string;
|
||||||
|
isMobile: boolean;
|
||||||
|
sidebarShortcutTargets: ReadonlyArray<SidebarShortcutWorkspaceTarget>;
|
||||||
|
navigationActiveWorkspace: SidebarShortcutWorkspaceTarget | null;
|
||||||
|
lastNavigationWorkspaceRoute: SidebarShortcutWorkspaceTarget | null;
|
||||||
|
commandCenterOpen: boolean;
|
||||||
|
shortcutsDialogOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortcutRoutingInput {
|
||||||
|
action: string;
|
||||||
|
payload: KeyboardShortcutPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShortcutCallbackName =
|
||||||
|
| "toggle-agent-list"
|
||||||
|
| "toggle-both-sidebars"
|
||||||
|
| "toggle-focus-mode"
|
||||||
|
| "cycle-theme";
|
||||||
|
|
||||||
|
export type ShortcutAction =
|
||||||
|
| { kind: "none" }
|
||||||
|
| { kind: "dispatch"; action: KeyboardActionDefinition }
|
||||||
|
| { kind: "navigate-workspace"; serverId: string; workspaceId: string }
|
||||||
|
| { kind: "router-replace"; route: string }
|
||||||
|
| { kind: "router-back" }
|
||||||
|
| { kind: "router-push"; route: string }
|
||||||
|
| { kind: "open-project-picker" }
|
||||||
|
| { kind: "callback"; name: ShortcutCallbackName }
|
||||||
|
| { kind: "command-center-toggle"; nextOpen: boolean }
|
||||||
|
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
|
||||||
|
|
||||||
|
const NONE: ShortcutAction = { kind: "none" };
|
||||||
|
|
||||||
|
// Action ids whose routing is a no-payload pass-through to the dispatcher.
|
||||||
|
const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
|
||||||
|
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
|
||||||
|
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
|
||||||
|
"worktree.archive": { id: "worktree.archive", scope: "sidebar" },
|
||||||
|
"worktree.new": { id: "worktree.new", scope: "sidebar" },
|
||||||
|
"workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },
|
||||||
|
"workspace.tab.close.current": { id: "workspace.tab.close-current", scope: "workspace" },
|
||||||
|
"sidebar.toggle.right": { id: "sidebar.toggle.right", scope: "sidebar" },
|
||||||
|
"workspace.pane.split.right": { id: "workspace.pane.split.right", scope: "workspace" },
|
||||||
|
"workspace.pane.split.down": { id: "workspace.pane.split.down", scope: "workspace" },
|
||||||
|
"workspace.pane.focus.left": { id: "workspace.pane.focus.left", scope: "workspace" },
|
||||||
|
"workspace.pane.focus.right": { id: "workspace.pane.focus.right", scope: "workspace" },
|
||||||
|
"workspace.pane.focus.up": { id: "workspace.pane.focus.up", scope: "workspace" },
|
||||||
|
"workspace.pane.focus.down": { id: "workspace.pane.focus.down", scope: "workspace" },
|
||||||
|
"workspace.pane.move-tab.left": { id: "workspace.pane.move-tab.left", scope: "workspace" },
|
||||||
|
"workspace.pane.move-tab.right": { id: "workspace.pane.move-tab.right", scope: "workspace" },
|
||||||
|
"workspace.pane.move-tab.up": { id: "workspace.pane.move-tab.up", scope: "workspace" },
|
||||||
|
"workspace.pane.move-tab.down": { id: "workspace.pane.move-tab.down", scope: "workspace" },
|
||||||
|
"workspace.pane.close": { id: "workspace.pane.close", scope: "workspace" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIMPLE_CALLBACKS: Record<string, ShortcutCallbackName> = {
|
||||||
|
"sidebar.toggle.left": "toggle-agent-list",
|
||||||
|
"sidebar.toggle.both": "toggle-both-sidebars",
|
||||||
|
"view.toggle.focus": "toggle-focus-mode",
|
||||||
|
"theme.cycle": "cycle-theme",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MESSAGE_INPUT_DISPATCH: Record<
|
||||||
|
MessageInputKeyboardActionKind,
|
||||||
|
KeyboardActionDefinition | null
|
||||||
|
> = {
|
||||||
|
focus: { id: "message-input.focus", scope: "message-input" },
|
||||||
|
send: { id: "message-input.send", scope: "message-input" },
|
||||||
|
"dictation-toggle": { id: "message-input.dictation-toggle", scope: "message-input" },
|
||||||
|
"dictation-cancel": { id: "message-input.dictation-cancel", scope: "message-input" },
|
||||||
|
"dictation-confirm": { id: "message-input.dictation-confirm", scope: "message-input" },
|
||||||
|
"voice-toggle": { id: "message-input.voice-toggle", scope: "message-input" },
|
||||||
|
"voice-mute-toggle": { id: "message-input.voice-mute-toggle", scope: "message-input" },
|
||||||
|
queue: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasPayloadKey<K extends "index" | "delta" | "kind">(
|
||||||
|
payload: KeyboardShortcutPayload,
|
||||||
|
key: K,
|
||||||
|
): payload is Extract<KeyboardShortcutPayload, Record<K, unknown>> {
|
||||||
|
return !!payload && typeof payload === "object" && key in payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatch(action: KeyboardActionDefinition): ShortcutAction {
|
||||||
|
return { kind: "dispatch", action };
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeWorkspaceTabNavigateIndex(payload: KeyboardShortcutPayload): ShortcutAction {
|
||||||
|
if (!hasPayloadKey(payload, "index")) return NONE;
|
||||||
|
return dispatch({
|
||||||
|
id: "workspace.tab.navigate-index",
|
||||||
|
scope: "workspace",
|
||||||
|
index: payload.index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeWorkspaceTabNavigateRelative(payload: KeyboardShortcutPayload): ShortcutAction {
|
||||||
|
if (!hasPayloadKey(payload, "delta")) return NONE;
|
||||||
|
return dispatch({
|
||||||
|
id: "workspace.tab.navigate-relative",
|
||||||
|
scope: "workspace",
|
||||||
|
delta: payload.delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeWorkspaceNavigateIndex(
|
||||||
|
payload: KeyboardShortcutPayload,
|
||||||
|
ctx: ShortcutRoutingContext,
|
||||||
|
): ShortcutAction {
|
||||||
|
if (!hasPayloadKey(payload, "index")) return NONE;
|
||||||
|
const target = ctx.sidebarShortcutTargets[payload.index - 1] ?? null;
|
||||||
|
if (!target) return NONE;
|
||||||
|
return {
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: target.serverId,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeWorkspaceNavigateRelative(
|
||||||
|
payload: KeyboardShortcutPayload,
|
||||||
|
ctx: ShortcutRoutingContext,
|
||||||
|
): ShortcutAction {
|
||||||
|
if (!hasPayloadKey(payload, "delta")) return NONE;
|
||||||
|
if (ctx.sidebarShortcutTargets.length === 0) return NONE;
|
||||||
|
|
||||||
|
const currentWorkspace =
|
||||||
|
ctx.navigationActiveWorkspace ?? parseHostWorkspaceRouteFromPathname(ctx.pathname);
|
||||||
|
const target = getRelativeSidebarShortcutTarget({
|
||||||
|
targets: ctx.sidebarShortcutTargets,
|
||||||
|
currentTarget: currentWorkspace
|
||||||
|
? { serverId: currentWorkspace.serverId, workspaceId: currentWorkspace.workspaceId }
|
||||||
|
: null,
|
||||||
|
delta: payload.delta,
|
||||||
|
});
|
||||||
|
if (!target) return NONE;
|
||||||
|
return {
|
||||||
|
kind: "navigate-workspace",
|
||||||
|
serverId: target.serverId,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeMessageInputAction(payload: KeyboardShortcutPayload): ShortcutAction {
|
||||||
|
if (!hasPayloadKey(payload, "kind")) return NONE;
|
||||||
|
const action = MESSAGE_INPUT_DISPATCH[payload.kind];
|
||||||
|
if (!action) return NONE;
|
||||||
|
return dispatch(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeSettingsToggle(ctx: ShortcutRoutingContext): ShortcutAction {
|
||||||
|
if (!ctx.pathname.startsWith("/settings")) {
|
||||||
|
return { kind: "router-push", route: buildSettingsRoute() };
|
||||||
|
}
|
||||||
|
if (!ctx.isMobile && ctx.lastNavigationWorkspaceRoute) {
|
||||||
|
return {
|
||||||
|
kind: "router-replace",
|
||||||
|
route: buildHostWorkspaceRoute(
|
||||||
|
ctx.lastNavigationWorkspaceRoute.serverId,
|
||||||
|
ctx.lastNavigationWorkspaceRoute.workspaceId,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { kind: "router-back" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeKeyboardShortcut(
|
||||||
|
input: ShortcutRoutingInput,
|
||||||
|
ctx: ShortcutRoutingContext,
|
||||||
|
): ShortcutAction {
|
||||||
|
const passthrough = PASSTHROUGH_DISPATCH[input.action];
|
||||||
|
if (passthrough) {
|
||||||
|
return dispatch(passthrough);
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback = SIMPLE_CALLBACKS[input.action];
|
||||||
|
if (callback) {
|
||||||
|
return { kind: "callback", name: callback };
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (input.action) {
|
||||||
|
case "workspace.tab.navigate.index":
|
||||||
|
return routeWorkspaceTabNavigateIndex(input.payload);
|
||||||
|
case "workspace.tab.navigate.relative":
|
||||||
|
return routeWorkspaceTabNavigateRelative(input.payload);
|
||||||
|
case "workspace.navigate.index":
|
||||||
|
return routeWorkspaceNavigateIndex(input.payload, ctx);
|
||||||
|
case "workspace.navigate.relative":
|
||||||
|
return routeWorkspaceNavigateRelative(input.payload, ctx);
|
||||||
|
case "message-input.action":
|
||||||
|
return routeMessageInputAction(input.payload);
|
||||||
|
case "agent.new":
|
||||||
|
return { kind: "open-project-picker" };
|
||||||
|
case "settings.toggle":
|
||||||
|
return routeSettingsToggle(ctx);
|
||||||
|
case "command-center.toggle":
|
||||||
|
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
|
||||||
|
case "shortcuts.dialog.toggle":
|
||||||
|
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
|
||||||
|
default:
|
||||||
|
return NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,570 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React, { useImperativeHandle } from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { DaemonClient } from "@server/client/daemon-client";
|
|
||||||
import { PaneFocusProvider, PaneProvider, type PaneFocusContextValue } from "@/panels/pane-context";
|
|
||||||
import { agentPanelRegistration } from "@/panels/agent-panel";
|
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
|
||||||
import type { PendingPermission } from "@/types/shared";
|
|
||||||
import type { StreamItem } from "@/types/stream";
|
|
||||||
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
interface PanelTestTheme {
|
|
||||||
colors: {
|
|
||||||
foreground: string;
|
|
||||||
foregroundMuted: string;
|
|
||||||
surface0: string;
|
|
||||||
surface1: string;
|
|
||||||
surface2: string;
|
|
||||||
surface3: string;
|
|
||||||
border: string;
|
|
||||||
destructive: string;
|
|
||||||
};
|
|
||||||
spacing: Record<number, number>;
|
|
||||||
borderRadius: Record<string, number>;
|
|
||||||
fontSize: Record<string, number>;
|
|
||||||
fontWeight: Record<string, string>;
|
|
||||||
iconSize: Record<string, number>;
|
|
||||||
}
|
|
||||||
type PanelTestStyles = Record<string, unknown>;
|
|
||||||
type PanelTestStyleFactory = (input: PanelTestTheme) => PanelTestStyles;
|
|
||||||
|
|
||||||
const {
|
|
||||||
composerRenderCount,
|
|
||||||
composerUnmountCount,
|
|
||||||
latestComposerCwd,
|
|
||||||
latestComposerIsPaneFocused,
|
|
||||||
streamRenderCount,
|
|
||||||
latestStreamPermissionKeys,
|
|
||||||
latestStreamText,
|
|
||||||
runtimeIsConnected,
|
|
||||||
theme,
|
|
||||||
runtimeClient,
|
|
||||||
} = vi.hoisted(() => {
|
|
||||||
Object.defineProperty(globalThis, "__DEV__", {
|
|
||||||
value: false,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
||||||
value: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
|
||||||
value: class ResizeObserver {
|
|
||||||
observe() {}
|
|
||||||
unobserve() {}
|
|
||||||
disconnect() {}
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
composerRenderCount: vi.fn(),
|
|
||||||
composerUnmountCount: vi.fn(),
|
|
||||||
latestComposerCwd: { current: null as string | null },
|
|
||||||
latestComposerIsPaneFocused: { current: null as boolean | null },
|
|
||||||
streamRenderCount: vi.fn(),
|
|
||||||
latestStreamPermissionKeys: { current: [] as string[] },
|
|
||||||
latestStreamText: { current: null as string | null },
|
|
||||||
runtimeIsConnected: { current: false },
|
|
||||||
theme: {
|
|
||||||
colors: {
|
|
||||||
foreground: "#ffffff",
|
|
||||||
foregroundMuted: "#999999",
|
|
||||||
surface0: "#000000",
|
|
||||||
surface1: "#111111",
|
|
||||||
surface2: "#222222",
|
|
||||||
surface3: "#333333",
|
|
||||||
border: "#444444",
|
|
||||||
destructive: "#ff0000",
|
|
||||||
},
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
|
|
||||||
borderRadius: { md: 6, lg: 8, xl: 12 },
|
|
||||||
fontSize: { sm: 13, base: 15, lg: 18 },
|
|
||||||
fontWeight: { medium: "500" },
|
|
||||||
iconSize: { lg: 22 },
|
|
||||||
} satisfies PanelTestTheme,
|
|
||||||
runtimeClient: {
|
|
||||||
fetchAgent: vi.fn(),
|
|
||||||
fetchAgentTimeline: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("react-native-reanimated", () => ({
|
|
||||||
default: {
|
|
||||||
View: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => {
|
|
||||||
return {
|
|
||||||
StyleSheet: {
|
|
||||||
create: createPanelTestStyles,
|
|
||||||
},
|
|
||||||
withUnistyles: <T,>(component: T) => component,
|
|
||||||
useUnistyles: () => ({ theme, rt: { breakpoint: "lg" } }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("react-native-safe-area-context", () => ({
|
|
||||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles): PanelTestStyles {
|
|
||||||
return typeof factory === "function" ? factory(theme) : factory;
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHosts: () => [{ serverId: "server", label: "Test server" }],
|
|
||||||
useHostRuntimeClient: () => runtimeClient,
|
|
||||||
useHostRuntimeIsConnected: () => runtimeIsConnected.current,
|
|
||||||
useHostRuntimeConnectionStatus: () => (runtimeIsConnected.current ? "online" : "offline"),
|
|
||||||
useHostRuntimeLastError: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/attachments/service", () => ({
|
|
||||||
garbageCollectAttachments: vi.fn(async () => {}),
|
|
||||||
persistAttachmentFromDataUrl: vi.fn(),
|
|
||||||
persistAttachmentFromFileUri: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/provider-icons", () => ({
|
|
||||||
getProviderIcon: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/file-drop-zone", () => ({
|
|
||||||
FileDropZone: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/toast-host", () => ({
|
|
||||||
ToastViewport: () => null,
|
|
||||||
useToastHost: () => ({
|
|
||||||
toast: null,
|
|
||||||
dismiss: vi.fn(),
|
|
||||||
api: {
|
|
||||||
show: vi.fn(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/archived-agent-callout", () => ({
|
|
||||||
ArchivedAgentCallout: ({ agentId }: { agentId: string }) => (
|
|
||||||
<div data-testid="archived-agent-callout">{agentId}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
|
|
||||||
useKeyboardShiftStyle: () => ({ style: null }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-archive-agent", () => ({
|
|
||||||
useArchiveAgent: () => ({
|
|
||||||
isArchivingAgent: () => false,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/composer", () => ({
|
|
||||||
Composer: ({ cwd, isPaneFocused }: { cwd: string; isPaneFocused: boolean }) => {
|
|
||||||
React.useEffect(
|
|
||||||
() => () => {
|
|
||||||
composerUnmountCount();
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
composerRenderCount();
|
|
||||||
latestComposerCwd.current = cwd;
|
|
||||||
latestComposerIsPaneFocused.current = isPaneFocused;
|
|
||||||
return <div data-testid="composer">{cwd}</div>;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/agent-stream-view", () => ({
|
|
||||||
AgentStreamView: React.memo(
|
|
||||||
React.forwardRef<
|
|
||||||
{ prepareForViewportChange: () => void; scrollToBottom: (reason: "message-sent") => void },
|
|
||||||
{ pendingPermissions: Map<string, PendingPermission>; streamItems: StreamItem[] }
|
|
||||||
>(function AgentStreamView({ pendingPermissions, streamItems }, ref) {
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
prepareForViewportChange: vi.fn(),
|
|
||||||
scrollToBottom: vi.fn(),
|
|
||||||
}));
|
|
||||||
streamRenderCount();
|
|
||||||
latestStreamPermissionKeys.current = Array.from(pendingPermissions.keys());
|
|
||||||
latestStreamText.current =
|
|
||||||
streamItems.find((item) => item.kind === "user_message")?.text ?? null;
|
|
||||||
return <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function makeClient(): DaemonClient {
|
|
||||||
return new DaemonClient({
|
|
||||||
url: "ws://127.0.0.1:1",
|
|
||||||
clientId: "panel-render-isolation-test",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
|
||||||
const now = new Date("2026-04-20T00:00:00.000Z");
|
|
||||||
return {
|
|
||||||
serverId: "server",
|
|
||||||
id: "agent",
|
|
||||||
provider: "codex",
|
|
||||||
status: "running",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
lastUserMessageAt: null,
|
|
||||||
lastActivityAt: now,
|
|
||||||
capabilities: {
|
|
||||||
supportsStreaming: true,
|
|
||||||
supportsSessionPersistence: true,
|
|
||||||
supportsDynamicModes: true,
|
|
||||||
supportsMcpServers: true,
|
|
||||||
supportsReasoningStream: true,
|
|
||||||
supportsToolInvocations: true,
|
|
||||||
},
|
|
||||||
currentModeId: null,
|
|
||||||
availableModes: [],
|
|
||||||
pendingPermissions: [],
|
|
||||||
persistence: null,
|
|
||||||
lastError: null,
|
|
||||||
title: "Render isolation",
|
|
||||||
cwd: "/workspace/one",
|
|
||||||
model: null,
|
|
||||||
labels: {},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFetchedAgentResult(agent: Agent): Awaited<ReturnType<DaemonClient["fetchAgent"]>> {
|
|
||||||
return {
|
|
||||||
project: {
|
|
||||||
projectKey: agent.cwd,
|
|
||||||
projectName: "workspace",
|
|
||||||
checkout: {
|
|
||||||
cwd: agent.cwd,
|
|
||||||
isGit: false,
|
|
||||||
currentBranch: null,
|
|
||||||
remoteUrl: null,
|
|
||||||
worktreeRoot: null,
|
|
||||||
isPaseoOwnedWorktree: false,
|
|
||||||
mainRepoRoot: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
agent: {
|
|
||||||
id: agent.id,
|
|
||||||
provider: agent.provider,
|
|
||||||
status: agent.status,
|
|
||||||
createdAt: agent.createdAt.toISOString(),
|
|
||||||
updatedAt: agent.updatedAt.toISOString(),
|
|
||||||
lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null,
|
|
||||||
runtimeInfo: agent.runtimeInfo ?? {
|
|
||||||
provider: agent.provider,
|
|
||||||
sessionId: null,
|
|
||||||
},
|
|
||||||
capabilities: agent.capabilities,
|
|
||||||
currentModeId: agent.currentModeId,
|
|
||||||
availableModes: agent.availableModes,
|
|
||||||
pendingPermissions: agent.pendingPermissions,
|
|
||||||
persistence: agent.persistence,
|
|
||||||
title: agent.title,
|
|
||||||
cwd: agent.cwd,
|
|
||||||
model: agent.model,
|
|
||||||
thinkingOptionId: agent.thinkingOptionId ?? null,
|
|
||||||
requiresAttention: agent.requiresAttention ?? false,
|
|
||||||
attentionReason: agent.attentionReason ?? null,
|
|
||||||
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
|
|
||||||
archivedAt: agent.archivedAt?.toISOString() ?? null,
|
|
||||||
labels: agent.labels,
|
|
||||||
lastError: agent.lastError ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedReadyAgent(agent: Agent = makeAgent()) {
|
|
||||||
const store = useSessionStore.getState();
|
|
||||||
store.initializeSession("server", makeClient());
|
|
||||||
store.setAgents("server", new Map([["agent", agent]]));
|
|
||||||
store.setAgentAuthoritativeHistoryApplied("server", "agent", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTestPaneValue() {
|
|
||||||
return {
|
|
||||||
serverId: "server",
|
|
||||||
workspaceId: "workspace",
|
|
||||||
tabId: "agent-agent",
|
|
||||||
target: { kind: "agent" as const, agentId: "agent" },
|
|
||||||
openTab: vi.fn(),
|
|
||||||
closeCurrentTab: vi.fn(),
|
|
||||||
retargetCurrentTab: vi.fn(),
|
|
||||||
openFileInWorkspace: vi.fn(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderAgentPanel(
|
|
||||||
root: Root,
|
|
||||||
focus: PaneFocusContextValue = {
|
|
||||||
isWorkspaceFocused: true,
|
|
||||||
isPaneFocused: false,
|
|
||||||
isInteractive: false,
|
|
||||||
focusPane: vi.fn(),
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const AgentPanel = agentPanelRegistration.component;
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: { retry: false },
|
|
||||||
mutations: { retry: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const paneValue = buildTestPaneValue();
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<PaneProvider value={paneValue}>
|
|
||||||
<PaneFocusProvider value={focus}>
|
|
||||||
<AgentPanel />
|
|
||||||
</PaneFocusProvider>
|
|
||||||
</PaneProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCurrentAgentStream(text: string) {
|
|
||||||
act(() => {
|
|
||||||
useSessionStore.getState().setAgentStreamTail("server", (previous) => {
|
|
||||||
const next = new Map(previous);
|
|
||||||
next.set("agent", [
|
|
||||||
{
|
|
||||||
kind: "user_message",
|
|
||||||
id: `message-${text}`,
|
|
||||||
text,
|
|
||||||
timestamp: new Date("2026-04-20T00:00:01.000Z"),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makePendingPermission(agentId: string, requestId: string): PendingPermission {
|
|
||||||
const request: AgentPermissionRequest = {
|
|
||||||
id: requestId,
|
|
||||||
provider: "codex",
|
|
||||||
name: `permission-${requestId}`,
|
|
||||||
kind: "tool",
|
|
||||||
};
|
|
||||||
const key = `${agentId}:${requestId}`;
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
agentId,
|
|
||||||
request,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePendingPermissions(permissions: PendingPermission[]) {
|
|
||||||
act(() => {
|
|
||||||
useSessionStore
|
|
||||||
.getState()
|
|
||||||
.setPendingPermissions(
|
|
||||||
"server",
|
|
||||||
new Map(permissions.map((permission) => [permission.key, permission])),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateCurrentAgentCwd(cwd: string) {
|
|
||||||
await act(async () => {
|
|
||||||
useSessionStore.getState().setAgents("server", (previous) => {
|
|
||||||
const current = previous.get("agent");
|
|
||||||
if (!current) {
|
|
||||||
throw new Error("Expected seeded agent");
|
|
||||||
}
|
|
||||||
const next = new Map(previous);
|
|
||||||
next.set("agent", { ...current, cwd });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function archiveSeededAgent(previous: Map<string, Agent>): Map<string, Agent> {
|
|
||||||
const current = previous.get("agent");
|
|
||||||
if (!current) {
|
|
||||||
throw new Error("Expected seeded agent");
|
|
||||||
}
|
|
||||||
const next = new Map(previous);
|
|
||||||
next.set("agent", { ...current, archivedAt: new Date("2026-04-20T00:00:02.000Z") });
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("AgentPanel render isolation", () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
|
||||||
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
|
||||||
composerRenderCount.mockClear();
|
|
||||||
composerUnmountCount.mockClear();
|
|
||||||
latestComposerCwd.current = null;
|
|
||||||
latestComposerIsPaneFocused.current = null;
|
|
||||||
streamRenderCount.mockClear();
|
|
||||||
latestStreamPermissionKeys.current = [];
|
|
||||||
latestStreamText.current = null;
|
|
||||||
runtimeIsConnected.current = false;
|
|
||||||
runtimeClient.fetchAgent.mockReset();
|
|
||||||
runtimeClient.fetchAgentTimeline.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refreshes the stream view without invoking Composer for stream-only updates", async () => {
|
|
||||||
seedReadyAgent();
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
await renderAgentPanel(root);
|
|
||||||
const composerBaseline = composerRenderCount.mock.calls.length;
|
|
||||||
const streamBaseline = streamRenderCount.mock.calls.length;
|
|
||||||
expect(composerBaseline).toBeGreaterThan(0);
|
|
||||||
expect(streamBaseline).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
updateCurrentAgentStream("stream-only update");
|
|
||||||
|
|
||||||
expect(latestStreamText.current).toBe("stream-only update");
|
|
||||||
expect(streamRenderCount).toHaveBeenCalledTimes(streamBaseline + 1);
|
|
||||||
expect(composerRenderCount).toHaveBeenCalledTimes(composerBaseline);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not advertise the composer as focused when its workspace is hidden", async () => {
|
|
||||||
seedReadyAgent();
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
await renderAgentPanel(root, {
|
|
||||||
isWorkspaceFocused: false,
|
|
||||||
isPaneFocused: true,
|
|
||||||
isInteractive: false,
|
|
||||||
focusPane: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(latestComposerIsPaneFocused.current).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still invokes Composer for current-agent cwd changes and unmounts it for archives", async () => {
|
|
||||||
seedReadyAgent();
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
await renderAgentPanel(root);
|
|
||||||
expect(latestComposerCwd.current).toBe("/workspace/one");
|
|
||||||
const composerBaseline = composerRenderCount.mock.calls.length;
|
|
||||||
expect(composerBaseline).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
await updateCurrentAgentCwd("/workspace/two");
|
|
||||||
|
|
||||||
expect(latestComposerCwd.current).toBe("/workspace/two");
|
|
||||||
expect(composerRenderCount.mock.calls.length).toBeGreaterThan(composerBaseline);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
useSessionStore.getState().setAgents("server", archiveSeededAgent);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(composerUnmountCount).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps stream permissions stable for unrelated pending permission updates", async () => {
|
|
||||||
seedReadyAgent();
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
await renderAgentPanel(root);
|
|
||||||
const initialRenderCount = streamRenderCount.mock.calls.length;
|
|
||||||
expect(initialRenderCount).toBeGreaterThan(0);
|
|
||||||
expect(latestStreamPermissionKeys.current).toEqual([]);
|
|
||||||
|
|
||||||
const unrelatedPermission = makePendingPermission("other-agent", "unrelated");
|
|
||||||
updatePendingPermissions([unrelatedPermission]);
|
|
||||||
|
|
||||||
expect(latestStreamPermissionKeys.current).toEqual([]);
|
|
||||||
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount);
|
|
||||||
|
|
||||||
const agentPermission = makePendingPermission("agent", "current-agent");
|
|
||||||
updatePendingPermissions([unrelatedPermission, agentPermission]);
|
|
||||||
|
|
||||||
expect(latestStreamPermissionKeys.current).toEqual(["agent:current-agent"]);
|
|
||||||
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 1);
|
|
||||||
|
|
||||||
updatePendingPermissions([unrelatedPermission]);
|
|
||||||
|
|
||||||
expect(latestStreamPermissionKeys.current).toEqual([]);
|
|
||||||
expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders an archived lazy detail without adding it to the active agent store", async () => {
|
|
||||||
const archivedAgent = makeAgent({
|
|
||||||
archivedAt: new Date("2026-04-20T00:00:02.000Z"),
|
|
||||||
});
|
|
||||||
runtimeIsConnected.current = true;
|
|
||||||
runtimeClient.fetchAgent.mockResolvedValue(makeFetchedAgentResult(archivedAgent));
|
|
||||||
runtimeClient.fetchAgentTimeline.mockResolvedValue({
|
|
||||||
agent: null,
|
|
||||||
events: [],
|
|
||||||
nextCursor: null,
|
|
||||||
hasMore: false,
|
|
||||||
});
|
|
||||||
useSessionStore
|
|
||||||
.getState()
|
|
||||||
.initializeSession("server", runtimeClient as unknown as DaemonClient);
|
|
||||||
useSessionStore.getState().setAgentAuthoritativeHistoryApplied("server", "agent", true);
|
|
||||||
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
await renderAgentPanel(root, {
|
|
||||||
isWorkspaceFocused: true,
|
|
||||||
isPaneFocused: true,
|
|
||||||
isInteractive: true,
|
|
||||||
focusPane: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeoutAt = Date.now() + 300;
|
|
||||||
while (
|
|
||||||
!container?.querySelector('[data-testid="archived-agent-callout"]') &&
|
|
||||||
Date.now() < timeoutAt
|
|
||||||
) {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="archived-agent-callout"]')).not.toBeNull();
|
|
||||||
|
|
||||||
expect(useSessionStore.getState().sessions.server?.agents.has("agent")).toBe(false);
|
|
||||||
expect(
|
|
||||||
useSessionStore.getState().sessions.server?.agentDetails.get("agent")?.archivedAt,
|
|
||||||
).toEqual(archivedAgent.archivedAt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
|
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||||
|
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||||
|
|
||||||
|
function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem {
|
||||||
|
return {
|
||||||
|
kind: "pr",
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
url: `https://example.com/pull/${number}`,
|
||||||
|
state: "open",
|
||||||
|
body: null,
|
||||||
|
labels: [],
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||||
|
return { kind: "github_pr", item };
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueAttachment(number: number): UserComposerAttachment {
|
||||||
|
return {
|
||||||
|
kind: "github_issue",
|
||||||
|
item: {
|
||||||
|
kind: "issue",
|
||||||
|
number,
|
||||||
|
title: `Issue ${number}`,
|
||||||
|
url: `https://example.com/issues/${number}`,
|
||||||
|
state: "open",
|
||||||
|
body: null,
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("syncPickerPrAttachment", () => {
|
||||||
|
it("selects a PR when no previous picker PR is set", () => {
|
||||||
|
const pr = makePrItem(202, "Refactor picker");
|
||||||
|
const result = syncPickerPrAttachment({
|
||||||
|
attachments: [],
|
||||||
|
previousPickerPrNumber: null,
|
||||||
|
item: { kind: "github-pr", item: pr },
|
||||||
|
});
|
||||||
|
expect(result.attachedPrNumber).toBe(202);
|
||||||
|
expect(result.attachments).toEqual([prAttachment(pr)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a branch without modifying attachments when no previous picker PR", () => {
|
||||||
|
const issue = issueAttachment(44);
|
||||||
|
const result = syncPickerPrAttachment({
|
||||||
|
attachments: [issue],
|
||||||
|
previousPickerPrNumber: null,
|
||||||
|
item: { kind: "branch", name: "dev" },
|
||||||
|
});
|
||||||
|
expect(result.attachedPrNumber).toBeNull();
|
||||||
|
expect(result.attachments).toEqual([issue]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the previous picker PR when a different PR is selected", () => {
|
||||||
|
const prA = makePrItem(202, "Refactor picker", "feature/picker");
|
||||||
|
const prB = makePrItem(303, "Polish chip", "feature/chip");
|
||||||
|
const result = syncPickerPrAttachment({
|
||||||
|
attachments: [prAttachment(prA)],
|
||||||
|
previousPickerPrNumber: 202,
|
||||||
|
item: { kind: "github-pr", item: prB },
|
||||||
|
});
|
||||||
|
expect(result.attachedPrNumber).toBe(303);
|
||||||
|
expect(result.attachments).toEqual([prAttachment(prB)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the previous picker PR and adds no new attachment when a branch is selected", () => {
|
||||||
|
const pr = makePrItem(202, "Refactor picker");
|
||||||
|
const issue = issueAttachment(44);
|
||||||
|
const result = syncPickerPrAttachment({
|
||||||
|
attachments: [issue, prAttachment(pr)],
|
||||||
|
previousPickerPrNumber: 202,
|
||||||
|
item: { kind: "branch", name: "dev" },
|
||||||
|
});
|
||||||
|
expect(result.attachedPrNumber).toBeNull();
|
||||||
|
expect(result.attachments).toEqual([issue]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate a PR that was already manually attached by the user", () => {
|
||||||
|
const pr = makePrItem(202, "Refactor picker");
|
||||||
|
const result = syncPickerPrAttachment({
|
||||||
|
attachments: [prAttachment(pr)],
|
||||||
|
previousPickerPrNumber: null,
|
||||||
|
item: { kind: "github-pr", item: pr },
|
||||||
|
});
|
||||||
|
expect(result.attachedPrNumber).toBeNull();
|
||||||
|
expect(result.attachments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
|
import type { PickerItem } from "./new-workspace-picker-item";
|
||||||
|
|
||||||
|
// The picker "owns" at most one PR attachment at a time. When the user selects
|
||||||
|
// a different item the previously-owned PR is removed before the new one is added.
|
||||||
|
// User-added attachments for other PRs/issues are left untouched.
|
||||||
|
export function syncPickerPrAttachment(input: {
|
||||||
|
attachments: UserComposerAttachment[];
|
||||||
|
previousPickerPrNumber: number | null;
|
||||||
|
item: PickerItem;
|
||||||
|
}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } {
|
||||||
|
let nextAttachments = input.attachments;
|
||||||
|
let attachedPrNumber: number | null = null;
|
||||||
|
|
||||||
|
if (input.previousPickerPrNumber !== null) {
|
||||||
|
nextAttachments = nextAttachments.filter(
|
||||||
|
(attachment) =>
|
||||||
|
attachment.kind !== "github_pr" || attachment.item.number !== input.previousPickerPrNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.item.kind === "github-pr") {
|
||||||
|
const selectedPr = input.item.item;
|
||||||
|
const hasExistingPrAttachment = nextAttachments.some(
|
||||||
|
(attachment) =>
|
||||||
|
attachment.kind === "github_pr" && attachment.item.number === selectedPr.number,
|
||||||
|
);
|
||||||
|
if (!hasExistingPrAttachment) {
|
||||||
|
nextAttachments = [...nextAttachments, { kind: "github_pr", item: selectedPr }];
|
||||||
|
attachedPrNumber = selectedPr.number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attachments: nextAttachments, attachedPrNumber };
|
||||||
|
}
|
||||||
@@ -1,982 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { ComposerAttachment } from "@/attachments/types";
|
|
||||||
import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client";
|
|
||||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
|
||||||
import { NewWorkspaceScreen } from "./new-workspace-screen";
|
|
||||||
|
|
||||||
const {
|
|
||||||
theme,
|
|
||||||
mockClient,
|
|
||||||
mergeWorkspacesMock,
|
|
||||||
navigateMock,
|
|
||||||
saveDraftInputMock,
|
|
||||||
clearDraftInputMock,
|
|
||||||
queueDraftSubmissionMock,
|
|
||||||
createdAgent: _createdAgent,
|
|
||||||
createdWorkspace,
|
|
||||||
prItem,
|
|
||||||
prItemB,
|
|
||||||
issueItem,
|
|
||||||
initialAttachments,
|
|
||||||
initialDraftState,
|
|
||||||
} = vi.hoisted(() => {
|
|
||||||
const hoistedTheme = {
|
|
||||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
|
||||||
iconSize: { sm: 14, md: 18, lg: 22 },
|
|
||||||
borderWidth: { 1: 1 },
|
|
||||||
borderRadius: { md: 6, lg: 8, "2xl": 16 },
|
|
||||||
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
|
|
||||||
fontWeight: { medium: "500" },
|
|
||||||
shadow: { md: {} },
|
|
||||||
colors: {
|
|
||||||
surface0: "#000",
|
|
||||||
surface1: "#111",
|
|
||||||
surface2: "#222",
|
|
||||||
foreground: "#fff",
|
|
||||||
foregroundMuted: "#aaa",
|
|
||||||
popoverForeground: "#fff",
|
|
||||||
border: "#555",
|
|
||||||
destructive: "#ff453a",
|
|
||||||
palette: {
|
|
||||||
zinc: { 600: "#52525b" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedPrItem: GitHubSearchItem = {
|
|
||||||
kind: "pr",
|
|
||||||
number: 202,
|
|
||||||
title: "Refactor picker",
|
|
||||||
url: "https://example.com/pull/202",
|
|
||||||
state: "open",
|
|
||||||
body: null,
|
|
||||||
labels: [],
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature/picker",
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedPrItemB: GitHubSearchItem = {
|
|
||||||
kind: "pr",
|
|
||||||
number: 303,
|
|
||||||
title: "Polish composer chip",
|
|
||||||
url: "https://example.com/pull/303",
|
|
||||||
state: "open",
|
|
||||||
body: null,
|
|
||||||
labels: [],
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature/composer-chip",
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedIssueItem: GitHubSearchItem = {
|
|
||||||
kind: "issue",
|
|
||||||
number: 44,
|
|
||||||
title: "Keep manual attachment",
|
|
||||||
url: "https://example.com/issues/44",
|
|
||||||
state: "open",
|
|
||||||
body: null,
|
|
||||||
labels: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedInitialAttachments: ComposerAttachment[] = [];
|
|
||||||
const hoistedInitialDraftState = { text: "" };
|
|
||||||
|
|
||||||
const hoistedCreatedWorkspace = {
|
|
||||||
id: "workspace-1",
|
|
||||||
workspaceDirectory: "/repo/.paseo/worktrees/workspace-1",
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedCreatedAgent = {
|
|
||||||
id: "agent-1",
|
|
||||||
cwd: hoistedCreatedWorkspace.workspaceDirectory,
|
|
||||||
};
|
|
||||||
|
|
||||||
const hoistedMockClient = {
|
|
||||||
isConnected: true,
|
|
||||||
getCheckoutStatus: vi.fn(async () => ({ currentBranch: "main" })),
|
|
||||||
getBranchSuggestions: vi.fn(async () => ({ branches: ["main", "dev", "feat/x"] })),
|
|
||||||
searchGitHub: vi.fn(async () => ({
|
|
||||||
items: [hoistedPrItem, hoistedPrItemB],
|
|
||||||
githubFeaturesEnabled: true,
|
|
||||||
error: null,
|
|
||||||
})),
|
|
||||||
createPaseoWorktree: vi.fn(async (_input: CreatePaseoWorktreeInput) => ({
|
|
||||||
workspace: hoistedCreatedWorkspace,
|
|
||||||
error: null,
|
|
||||||
})),
|
|
||||||
createAgent: vi.fn(async () => hoistedCreatedAgent),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
theme: hoistedTheme,
|
|
||||||
mockClient: hoistedMockClient,
|
|
||||||
mergeWorkspacesMock: vi.fn(),
|
|
||||||
navigateMock: vi.fn(),
|
|
||||||
saveDraftInputMock: vi.fn(),
|
|
||||||
clearDraftInputMock: vi.fn(),
|
|
||||||
queueDraftSubmissionMock: vi.fn(),
|
|
||||||
createdAgent: hoistedCreatedAgent,
|
|
||||||
createdWorkspace: hoistedCreatedWorkspace,
|
|
||||||
prItem: hoistedPrItem,
|
|
||||||
prItemB: hoistedPrItemB,
|
|
||||||
issueItem: hoistedIssueItem,
|
|
||||||
initialAttachments: hoistedInitialAttachments,
|
|
||||||
initialDraftState: hoistedInitialDraftState,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
|
||||||
isWeb: true,
|
|
||||||
isNative: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/layout", () => ({
|
|
||||||
HEADER_INNER_HEIGHT: 48,
|
|
||||||
HEADER_INNER_HEIGHT_MOBILE: 56,
|
|
||||||
HEADER_TOP_PADDING_MOBILE: 8,
|
|
||||||
MAX_CONTENT_WIDTH: 900,
|
|
||||||
useIsCompactFormFactor: () => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-safe-area-context", () => ({
|
|
||||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
|
||||||
React.createElement("span", { ...props, "data-icon": name });
|
|
||||||
return {
|
|
||||||
ChevronDown: createIcon("ChevronDown"),
|
|
||||||
GitBranch: createIcon("GitBranch"),
|
|
||||||
GitPullRequest: createIcon("GitPullRequest"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function flattenReanimatedStyle(style: unknown) {
|
|
||||||
if (!Array.isArray(style)) {
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
return Object.assign({}, ...style.filter(Boolean));
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("react-native-reanimated", () => ({
|
|
||||||
default: {
|
|
||||||
View: ({
|
|
||||||
testID,
|
|
||||||
style,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement> & { testID?: string; style?: unknown }) => {
|
|
||||||
const flattenedStyle = flattenReanimatedStyle(style);
|
|
||||||
return <div {...props} data-testid={testID} style={flattenedStyle as React.CSSProperties} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHostRuntimeClient: () => mockClient,
|
|
||||||
useHostRuntimeIsConnected: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/session-store", () => ({
|
|
||||||
useSessionStore: (selector: (state: unknown) => unknown) =>
|
|
||||||
selector({
|
|
||||||
mergeWorkspaces: mergeWorkspacesMock,
|
|
||||||
}),
|
|
||||||
normalizeWorkspaceDescriptor: (workspace: unknown) => workspace,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/agent-snapshots", () => ({
|
|
||||||
normalizeAgentSnapshot: (agent: unknown) => agent,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/encode-images", () => ({
|
|
||||||
encodeImages: vi.fn(async () => []),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/error-messages", () => ({
|
|
||||||
toErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/workspace-execution", () => ({
|
|
||||||
requireWorkspaceExecutionAuthority: (input: { workspace: { workspaceDirectory: string } }) => ({
|
|
||||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/workspace-navigation", () => ({
|
|
||||||
navigateToPreparedWorkspaceTab: navigateMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/draft-keys", () => ({
|
|
||||||
buildDraftStoreKey: ({
|
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
draftId,
|
|
||||||
}: {
|
|
||||||
serverId: string;
|
|
||||||
agentId: string;
|
|
||||||
draftId?: string | null;
|
|
||||||
}) => (draftId ? `draft:${serverId}:${draftId}` : `agent:${serverId}:${agentId}`),
|
|
||||||
generateDraftId: () => "draft-new-workspace",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/draft-store", () => ({
|
|
||||||
useDraftStore: {
|
|
||||||
getState: () => ({
|
|
||||||
saveDraftInput: saveDraftInputMock,
|
|
||||||
clearDraftInput: clearDraftInputMock,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/workspace-draft-submission-store", () => ({
|
|
||||||
useWorkspaceDraftSubmissionStore: {
|
|
||||||
getState: () => ({
|
|
||||||
setPending: queueDraftSubmissionMock,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/contexts/toast-context", () => ({
|
|
||||||
useToast: () => ({ error: vi.fn() }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-agent-input-draft", () => ({
|
|
||||||
useAgentInputDraft: () => {
|
|
||||||
const [attachments, setAttachments] = React.useState<ComposerAttachment[]>(initialAttachments);
|
|
||||||
const [text, setText] = React.useState(initialDraftState.text);
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
setText,
|
|
||||||
attachments,
|
|
||||||
setAttachments,
|
|
||||||
cwd: "/repo",
|
|
||||||
composerState: {
|
|
||||||
selectedProvider: "claude-code",
|
|
||||||
selectedMode: "",
|
|
||||||
modeOptions: [],
|
|
||||||
effectiveModelId: null,
|
|
||||||
effectiveThinkingOptionId: null,
|
|
||||||
commandDraftConfig: undefined,
|
|
||||||
statusControls: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
|
|
||||||
useKeyboardShiftStyle: () => ({ style: { transform: "translateY(-216px)" } }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface ComposerMockProps {
|
|
||||||
onSubmitMessage: (payload: {
|
|
||||||
text: string;
|
|
||||||
attachments: ComposerAttachment[];
|
|
||||||
cwd: string;
|
|
||||||
}) => void;
|
|
||||||
submitBehavior?: "clear" | "preserve-and-lock";
|
|
||||||
submitIcon?: "arrow" | "return";
|
|
||||||
isSubmitLoading?: boolean;
|
|
||||||
value: string;
|
|
||||||
onChangeText: (text: string) => void;
|
|
||||||
attachments: ComposerAttachment[];
|
|
||||||
onChangeAttachments: (attachments: ComposerAttachment[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AttachmentPillProps {
|
|
||||||
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
|
|
||||||
attachments: ComposerAttachment[];
|
|
||||||
isDisabled: boolean;
|
|
||||||
onChangeAttachments: (attachments: ComposerAttachment[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposerMockAttachmentPill({
|
|
||||||
attachment,
|
|
||||||
attachments,
|
|
||||||
isDisabled,
|
|
||||||
onChangeAttachments,
|
|
||||||
}: AttachmentPillProps) {
|
|
||||||
const handleRemove = React.useCallback(() => {
|
|
||||||
onChangeAttachments(
|
|
||||||
attachments.filter(
|
|
||||||
(candidate) =>
|
|
||||||
candidate.kind !== attachment.kind || candidate.item.number !== attachment.item.number,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, [attachment, attachments, onChangeAttachments]);
|
|
||||||
return (
|
|
||||||
<div data-testid="composer-github-attachment-pill">
|
|
||||||
#{attachment.item.number} {attachment.item.title}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Remove ${attachment.kind === "github_pr" ? "PR" : "issue"} #${attachment.item.number}`}
|
|
||||||
disabled={isDisabled}
|
|
||||||
onClick={handleRemove}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComposerMock({
|
|
||||||
onSubmitMessage,
|
|
||||||
submitBehavior,
|
|
||||||
submitIcon,
|
|
||||||
isSubmitLoading,
|
|
||||||
value,
|
|
||||||
onChangeText,
|
|
||||||
attachments,
|
|
||||||
onChangeAttachments,
|
|
||||||
}: ComposerMockProps) {
|
|
||||||
const isDisabled = submitBehavior === "preserve-and-lock" && Boolean(isSubmitLoading);
|
|
||||||
const handleTextareaChange = React.useCallback(
|
|
||||||
(event: React.ChangeEvent<HTMLTextAreaElement>) => onChangeText(event.currentTarget.value),
|
|
||||||
[onChangeText],
|
|
||||||
);
|
|
||||||
const handleSubmit = React.useCallback(
|
|
||||||
() => onSubmitMessage({ text: value, attachments, cwd: "/repo" }),
|
|
||||||
[onSubmitMessage, value, attachments],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-testid="test-composer"
|
|
||||||
data-submit-behavior={submitBehavior}
|
|
||||||
data-submit-icon={submitIcon}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
aria-label="Message agent..."
|
|
||||||
disabled={isDisabled}
|
|
||||||
value={value}
|
|
||||||
onChange={handleTextareaChange}
|
|
||||||
/>
|
|
||||||
<button type="button" data-testid="message-input-attach-button" disabled={isDisabled}>
|
|
||||||
Attach
|
|
||||||
</button>
|
|
||||||
{attachments.map((attachment) =>
|
|
||||||
attachment.kind === "github_pr" || attachment.kind === "github_issue" ? (
|
|
||||||
<ComposerMockAttachmentPill
|
|
||||||
key={`${attachment.kind}-${attachment.item.number}`}
|
|
||||||
attachment={attachment}
|
|
||||||
attachments={attachments}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
onChangeAttachments={onChangeAttachments}
|
|
||||||
/>
|
|
||||||
) : null,
|
|
||||||
)}
|
|
||||||
<button type="button" data-testid="test-composer-submit" onClick={handleSubmit}>
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("@/components/composer", () => ({
|
|
||||||
Composer: ComposerMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/composer-attachments", () => ({
|
|
||||||
splitComposerAttachmentsForSubmit: (attachments: ComposerAttachment[]) => ({
|
|
||||||
images: [],
|
|
||||||
attachments: attachments.flatMap((attachment) => {
|
|
||||||
if (attachment.kind === "github_pr") {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: "github_pr",
|
|
||||||
mimeType: "application/github-pr",
|
|
||||||
number: attachment.item.number,
|
|
||||||
title: attachment.item.title,
|
|
||||||
url: attachment.item.url,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (attachment.kind === "github_issue") {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: "github_issue",
|
|
||||||
mimeType: "application/github-issue",
|
|
||||||
number: attachment.item.number,
|
|
||||||
title: attachment.item.title,
|
|
||||||
url: attachment.item.url,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/desktop/titlebar-drag-region", () => ({
|
|
||||||
TitlebarDragRegion: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/headers/menu-header", () => ({
|
|
||||||
SidebarMenuToggle: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/headers/screen-header", () => ({
|
|
||||||
ScreenHeader: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/tooltip", () => ({
|
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function ComboboxOptionButton({
|
|
||||||
option,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
option: { id: string; label: string };
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const handleClick = React.useCallback(() => onSelect(option.id), [onSelect, option.id]);
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={handleClick}>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComboboxOptionRenderWrapper({
|
|
||||||
option,
|
|
||||||
onSelect,
|
|
||||||
renderOption,
|
|
||||||
}: {
|
|
||||||
option: { id: string; label: string };
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
renderOption: (input: {
|
|
||||||
option: { id: string; label: string };
|
|
||||||
selected: boolean;
|
|
||||||
active: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}) => React.ReactElement;
|
|
||||||
}) {
|
|
||||||
const handlePress = React.useCallback(() => onSelect(option.id), [onSelect, option.id]);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{renderOption({
|
|
||||||
option,
|
|
||||||
selected: false,
|
|
||||||
active: false,
|
|
||||||
onPress: handlePress,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/combobox", () => ({
|
|
||||||
Combobox: ({
|
|
||||||
open,
|
|
||||||
options,
|
|
||||||
renderOption,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
open?: boolean;
|
|
||||||
options: Array<{ id: string; label: string }>;
|
|
||||||
renderOption?: (input: {
|
|
||||||
option: { id: string; label: string };
|
|
||||||
selected: boolean;
|
|
||||||
active: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}) => React.ReactElement;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
}) => {
|
|
||||||
if (!open) return null;
|
|
||||||
return (
|
|
||||||
<div data-testid="ref-picker-combobox">
|
|
||||||
{options.map((option) =>
|
|
||||||
renderOption ? (
|
|
||||||
<ComboboxOptionRenderWrapper
|
|
||||||
key={option.id}
|
|
||||||
option={option}
|
|
||||||
onSelect={onSelect}
|
|
||||||
renderOption={renderOption}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ComboboxOptionButton key={option.id} option={option} onSelect={onSelect} />
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ComboboxItem: ({
|
|
||||||
label,
|
|
||||||
onPress,
|
|
||||||
testID,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
onPress: () => void;
|
|
||||||
testID?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) => (
|
|
||||||
<button type="button" data-testid={testID} disabled={disabled} onClick={onPress}>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("mnemonic-id", () => ({
|
|
||||||
createNameId: () => "gentle-slug",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native", async () => {
|
|
||||||
const actual = await vi.importActual<Record<string, unknown>>("react-native");
|
|
||||||
return actual;
|
|
||||||
});
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
let queryClient: QueryClient | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
vi.stubGlobal("window", dom.window);
|
|
||||||
vi.stubGlobal("document", dom.window.document);
|
|
||||||
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
|
||||||
vi.stubGlobal("Node", dom.window.Node);
|
|
||||||
vi.stubGlobal("navigator", dom.window.navigator);
|
|
||||||
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
mockClient.getBranchSuggestions.mockClear();
|
|
||||||
mockClient.searchGitHub.mockClear();
|
|
||||||
mockClient.searchGitHub.mockResolvedValue({
|
|
||||||
items: [prItem, prItemB],
|
|
||||||
githubFeaturesEnabled: true,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
mockClient.createPaseoWorktree.mockClear();
|
|
||||||
mockClient.createAgent.mockClear();
|
|
||||||
saveDraftInputMock.mockClear();
|
|
||||||
clearDraftInputMock.mockClear();
|
|
||||||
queueDraftSubmissionMock.mockClear();
|
|
||||||
navigateMock.mockClear();
|
|
||||||
initialAttachments.length = 0;
|
|
||||||
initialDraftState.text = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
queryClient?.clear();
|
|
||||||
root = null;
|
|
||||||
container = null;
|
|
||||||
queryClient = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderScreen() {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<QueryClientProvider client={queryClient!}>
|
|
||||||
<NewWorkspaceScreen serverId="server" sourceDirectory="/repo" />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function click(element: Element) {
|
|
||||||
act(() => {
|
|
||||||
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flush() {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findByTestId(testID: string): Promise<HTMLElement> {
|
|
||||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
||||||
await flush();
|
|
||||||
const element = document.querySelector(`[data-testid="${testID}"]`) as HTMLElement | null;
|
|
||||||
if (element) return element;
|
|
||||||
}
|
|
||||||
throw new Error(`Missing element with testID ${testID}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryByTestId(testID: string): HTMLElement | null {
|
|
||||||
return document.querySelector(`[data-testid="${testID}"]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function queryAllByTestId(testID: string): HTMLElement[] {
|
|
||||||
return Array.from(document.querySelectorAll(`[data-testid="${testID}"]`)) as HTMLElement[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreatePaseoWorktreeArg = Parameters<typeof mockClient.createPaseoWorktree>[0];
|
|
||||||
|
|
||||||
function createDeferredPromise<T>() {
|
|
||||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
||||||
let reject!: (reason?: unknown) => void;
|
|
||||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
|
||||||
resolve = nextResolve;
|
|
||||||
reject = nextReject;
|
|
||||||
});
|
|
||||||
return { promise, resolve, reject };
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstCreateWorktreeCall(): CreatePaseoWorktreeArg {
|
|
||||||
const calls = mockClient.createPaseoWorktree.mock.calls;
|
|
||||||
const firstCall = calls[0];
|
|
||||||
if (!firstCall) throw new Error("createPaseoWorktree not called");
|
|
||||||
return firstCall[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("NewWorkspaceScreen picker payload", () => {
|
|
||||||
it("moves the ref picker row with the mobile keyboard shift", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const pickerRow = await findByTestId("new-workspace-ref-picker-row");
|
|
||||||
expect(pickerRow.style.transform).toBe("translateY(-216px)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lazily searches only GitHub PRs when the picker opens", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.searchGitHub).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
|
|
||||||
cwd: "/repo",
|
|
||||||
query: "",
|
|
||||||
limit: 20,
|
|
||||||
kinds: ["github-pr"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends no new fields when nothing is selected (default)", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
|
|
||||||
const call = firstCreateWorktreeCall();
|
|
||||||
expect(call).toMatchObject({
|
|
||||||
cwd: "/repo",
|
|
||||||
worktreeSlug: "gentle-slug",
|
|
||||||
});
|
|
||||||
expect(call).not.toHaveProperty("refName");
|
|
||||||
expect(call).not.toHaveProperty("action");
|
|
||||||
expect(call).not.toHaveProperty("githubPrNumber");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens the new workspace draft tab as soon as the worktree is ready", async () => {
|
|
||||||
initialDraftState.text = "please review this change";
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
|
|
||||||
expect(firstCreateWorktreeCall().firstAgentContext?.prompt).toBe("please review this change");
|
|
||||||
expect(mockClient.createAgent).not.toHaveBeenCalled();
|
|
||||||
expect(saveDraftInputMock).toHaveBeenCalledWith({
|
|
||||||
draftKey: "draft:server:draft-new-workspace",
|
|
||||||
draft: {
|
|
||||||
text: "please review this change",
|
|
||||||
attachments: [],
|
|
||||||
cwd: createdWorkspace.workspaceDirectory,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(queueDraftSubmissionMock).toHaveBeenCalledWith({
|
|
||||||
serverId: "server",
|
|
||||||
workspaceId: createdWorkspace.id,
|
|
||||||
draftId: "draft-new-workspace",
|
|
||||||
text: "please review this change",
|
|
||||||
attachments: [],
|
|
||||||
cwd: createdWorkspace.workspaceDirectory,
|
|
||||||
provider: "claude-code",
|
|
||||||
allowEmptyText: true,
|
|
||||||
});
|
|
||||||
expect(navigateMock).toHaveBeenCalledWith({
|
|
||||||
serverId: "server",
|
|
||||||
workspaceId: createdWorkspace.id,
|
|
||||||
target: { kind: "draft", draftId: "draft-new-workspace" },
|
|
||||||
navigationMethod: "replace",
|
|
||||||
});
|
|
||||||
expect(clearDraftInputMock).toHaveBeenCalledWith({
|
|
||||||
draftKey: "new-workspace:server:/repo",
|
|
||||||
lifecycle: "sent",
|
|
||||||
});
|
|
||||||
expect(navigateMock.mock.invocationCallOrder[0]).toBeLessThan(
|
|
||||||
clearDraftInputMock.mock.invocationCallOrder[0],
|
|
||||||
);
|
|
||||||
expect(document.querySelector("textarea")).toHaveProperty("disabled", true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the selected PR number, title, and PR icon in the picker trigger", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const trigger = await findByTestId("new-workspace-ref-picker-trigger");
|
|
||||||
expect(trigger.textContent).toContain(`#${prItem.number}`);
|
|
||||||
expect(trigger.textContent).toContain(prItem.title);
|
|
||||||
expect(trigger.textContent).not.toContain("feature/picker");
|
|
||||||
expect(trigger.querySelectorAll('[data-icon="GitPullRequest"]')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds the selected picker PR as a composer attachment pill", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const pills = queryAllByTestId("composer-github-attachment-pill");
|
|
||||||
expect(pills).toHaveLength(1);
|
|
||||||
expect(pills[0]?.textContent).toContain(`#${prItem.number}`);
|
|
||||||
expect(pills[0]?.textContent).toContain("Refactor picker");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces the picker-owned composer PR pill when another PR is selected", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItemB.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const pills = queryAllByTestId("composer-github-attachment-pill");
|
|
||||||
expect(pills).toHaveLength(1);
|
|
||||||
expect(pills[0]?.textContent).toContain(`#${prItemB.number}`);
|
|
||||||
expect(pills[0]?.textContent).toContain("Polish composer chip");
|
|
||||||
expect(pills[0]?.textContent).not.toContain(`#${prItem.number}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes the picker-owned PR pill when switching to a branch and preserves user-added chips", async () => {
|
|
||||||
initialAttachments.push({ kind: "github_issue", item: issueItem });
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-branch-dev"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const pills = queryAllByTestId("composer-github-attachment-pill");
|
|
||||||
expect(pills).toHaveLength(1);
|
|
||||||
expect(pills[0]?.textContent).toContain(`#${issueItem.number}`);
|
|
||||||
expect(pills[0]?.textContent).toContain(issueItem.title);
|
|
||||||
expect(pills[0]?.textContent).not.toContain(`#${prItem.number}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends action=branch-off with the branch name when a branch row is selected", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-branch-dev"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
|
|
||||||
const call = firstCreateWorktreeCall();
|
|
||||||
expect(call).toMatchObject({
|
|
||||||
cwd: "/repo",
|
|
||||||
worktreeSlug: "gentle-slug",
|
|
||||||
action: "branch-off",
|
|
||||||
refName: "dev",
|
|
||||||
});
|
|
||||||
expect(call).not.toHaveProperty("githubPrNumber");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends action=checkout with pr number + head ref when a github PR row is selected", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
|
|
||||||
const call = firstCreateWorktreeCall();
|
|
||||||
expect(call).toMatchObject({
|
|
||||||
cwd: "/repo",
|
|
||||||
worktreeSlug: "gentle-slug",
|
|
||||||
action: "checkout",
|
|
||||||
refName: "feature/picker",
|
|
||||||
githubPrNumber: 202,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends the selected PR attachment when creating the worktree", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const call = firstCreateWorktreeCall();
|
|
||||||
const attachments = call.firstAgentContext?.attachments ?? [];
|
|
||||||
expect(attachments).toHaveLength(1);
|
|
||||||
expect(attachments[0]).toMatchObject({ type: "github_pr", number: 202 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the selected PR attachment when the PR query refreshes after selection", async () => {
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
mockClient.searchGitHub.mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
githubFeaturesEnabled: true,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
await queryClient?.invalidateQueries({
|
|
||||||
queryKey: ["new-workspace-github-prs", "server", "/repo"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const call = firstCreateWorktreeCall();
|
|
||||||
expect(call.firstAgentContext?.attachments).toHaveLength(1);
|
|
||||||
expect(call.firstAgentContext?.attachments?.[0]).toMatchObject({
|
|
||||||
type: "github_pr",
|
|
||||||
number: 202,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits PR rows from the picker when GitHub features are disabled", async () => {
|
|
||||||
mockClient.searchGitHub.mockResolvedValueOnce({
|
|
||||||
items: [],
|
|
||||||
githubFeaturesEnabled: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(queryByTestId(`new-workspace-ref-picker-pr-${prItem.number}`)).toBeNull();
|
|
||||||
expect((await findByTestId("new-workspace-ref-picker-branch-dev")).textContent).toContain(
|
|
||||||
"dev",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves and locks the composer and picker while worktree creation is pending, then unlocks on error", async () => {
|
|
||||||
initialDraftState.text = "please review this change";
|
|
||||||
const createWorktree = createDeferredPromise<{
|
|
||||||
workspace: typeof createdWorkspace;
|
|
||||||
error: null;
|
|
||||||
}>();
|
|
||||||
mockClient.createPaseoWorktree.mockImplementationOnce(async () => await createWorktree.promise);
|
|
||||||
renderScreen();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId(`new-workspace-ref-picker-pr-${prItem.number}`));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
click(await findByTestId("test-composer-submit"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const textInput = document.querySelector('[aria-label="Message agent..."]');
|
|
||||||
const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`);
|
|
||||||
expect(queryByTestId("test-composer")?.dataset.submitBehavior).toBe("preserve-and-lock");
|
|
||||||
expect(queryByTestId("test-composer")?.dataset.submitIcon).toBe("return");
|
|
||||||
expect(textInput).toHaveProperty("value", "please review this change");
|
|
||||||
expect(textInput).toHaveProperty("disabled", true);
|
|
||||||
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
|
|
||||||
expect(removeButton).toHaveProperty("disabled", true);
|
|
||||||
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", true);
|
|
||||||
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
|
|
||||||
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", true);
|
|
||||||
expect(clearDraftInputMock).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
createWorktree.reject(new Error("Create worktree failed"));
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(textInput).toHaveProperty("value", "please review this change");
|
|
||||||
expect(textInput).toHaveProperty("disabled", false);
|
|
||||||
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
|
|
||||||
expect(removeButton).toHaveProperty("disabled", false);
|
|
||||||
expect(queryByTestId("message-input-attach-button")).toHaveProperty("disabled", false);
|
|
||||||
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", false);
|
|
||||||
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -31,6 +31,7 @@ import type { ImageAttachment, MessagePayload } from "@/components/message-input
|
|||||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
|
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
|
||||||
|
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||||
|
|
||||||
interface NewWorkspaceScreenProps {
|
interface NewWorkspaceScreenProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -194,36 +195,6 @@ function pickerItemTriggerLabel(item: PickerItem): string {
|
|||||||
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
|
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPickerPrAttachment(input: {
|
|
||||||
attachments: UserComposerAttachment[];
|
|
||||||
previousPickerPrNumber: number | null;
|
|
||||||
item: PickerItem;
|
|
||||||
}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } {
|
|
||||||
let nextAttachments = input.attachments;
|
|
||||||
let attachedPrNumber: number | null = null;
|
|
||||||
|
|
||||||
if (input.previousPickerPrNumber !== null) {
|
|
||||||
nextAttachments = nextAttachments.filter(
|
|
||||||
(attachment) =>
|
|
||||||
attachment.kind !== "github_pr" || attachment.item.number !== input.previousPickerPrNumber,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.item.kind === "github-pr") {
|
|
||||||
const selectedPr = input.item.item;
|
|
||||||
const hasExistingPrAttachment = nextAttachments.some(
|
|
||||||
(attachment) =>
|
|
||||||
attachment.kind === "github_pr" && attachment.item.number === selectedPr.number,
|
|
||||||
);
|
|
||||||
if (!hasExistingPrAttachment) {
|
|
||||||
nextAttachments = [...nextAttachments, { kind: "github_pr", item: selectedPr }];
|
|
||||||
attachedPrNumber = selectedPr.number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { attachments: nextAttachments, attachedPrNumber };
|
|
||||||
}
|
|
||||||
|
|
||||||
function computePickerOptionData(
|
function computePickerOptionData(
|
||||||
branchDetails: ReadonlyArray<{ name: string; committerDate: number }>,
|
branchDetails: ReadonlyArray<{ name: string; committerDate: number }>,
|
||||||
prItems: ReadonlyArray<GitHubSearchItem>,
|
prItems: ReadonlyArray<GitHubSearchItem>,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,158 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { AgentHistoryResult } from "@/hooks/use-agent-history";
|
|
||||||
import { SessionsScreen } from "@/screens/sessions-screen";
|
|
||||||
|
|
||||||
const { historyResult, navigate } = vi.hoisted(() => ({
|
|
||||||
historyResult: {
|
|
||||||
current: null as AgentHistoryResult | null,
|
|
||||||
},
|
|
||||||
navigate: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native", () => ({
|
|
||||||
View: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
|
|
||||||
React.createElement("div", props, children),
|
|
||||||
Text: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
|
|
||||||
React.createElement("span", props, children),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => {
|
|
||||||
const theme = {
|
|
||||||
spacing: { 4: 16, 6: 24 },
|
|
||||||
fontSize: { lg: 18 },
|
|
||||||
colors: {
|
|
||||||
surface0: "#111",
|
|
||||||
foregroundMuted: "#999",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ theme }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@react-navigation/native", () => ({
|
|
||||||
useIsFocused: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("expo-router", () => ({
|
|
||||||
router: {
|
|
||||||
navigate,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => ({
|
|
||||||
ChevronLeft: () => React.createElement("span", { "data-icon": "ChevronLeft" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/headers/menu-header", () => ({
|
|
||||||
MenuHeader: ({ title }: { title: string }) =>
|
|
||||||
React.createElement("header", { "data-testid": "menu-header" }, title),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/button", () => ({
|
|
||||||
Button: ({ children, onPress }: React.PropsWithChildren<{ onPress?: () => void }>) =>
|
|
||||||
React.createElement("button", { type: "button", onClick: onPress }, children),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
|
||||||
LoadingSpinner: ({ color, size }: { color: string; size?: string }) =>
|
|
||||||
React.createElement("div", {
|
|
||||||
"data-color": color,
|
|
||||||
"data-size": size,
|
|
||||||
"data-testid": "sessions-loading-spinner",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/agent-list", () => ({
|
|
||||||
AgentList: ({ agents }: { agents: unknown[] }) =>
|
|
||||||
React.createElement("div", { "data-agent-count": agents.length, "data-testid": "agent-list" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-agent-history", () => ({
|
|
||||||
useAgentHistory: () => {
|
|
||||||
if (!historyResult.current) {
|
|
||||||
throw new Error("Expected history result");
|
|
||||||
}
|
|
||||||
return historyResult.current;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/host-routes", () => ({
|
|
||||||
buildHostOpenProjectRoute: (serverId: string) => `/h/${serverId}/open-project`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function makeHistoryResult(overrides: Partial<AgentHistoryResult> = {}): AgentHistoryResult {
|
|
||||||
return {
|
|
||||||
agents: [],
|
|
||||||
isLoading: false,
|
|
||||||
isInitialLoad: false,
|
|
||||||
isRevalidating: false,
|
|
||||||
hasMore: false,
|
|
||||||
isLoadingMore: false,
|
|
||||||
refreshAll: vi.fn(),
|
|
||||||
loadMore: vi.fn(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("SessionsScreen", () => {
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
let root: Root | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
navigate.mockReset();
|
|
||||||
historyResult.current = makeHistoryResult();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
historyResult.current = null;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the shared loader during the initial React Query history load", () => {
|
|
||||||
historyResult.current = makeHistoryResult({ isInitialLoad: true, isLoading: true });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(<SessionsScreen serverId="server-1" />);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).not.toBeNull();
|
|
||||||
expect(container?.textContent).not.toContain("No sessions yet");
|
|
||||||
expect(container?.textContent).not.toContain("Back");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the empty state after history finishes loading with no sessions", () => {
|
|
||||||
historyResult.current = makeHistoryResult();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(<SessionsScreen serverId="server-1" />);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container?.querySelector('[data-testid="sessions-loading-spinner"]')).toBeNull();
|
|
||||||
expect(container?.textContent).toContain("No sessions yet");
|
|
||||||
expect(container?.textContent).toContain("Back");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import React from "react";
|
|
||||||
import { act } from "react";
|
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import type { ComposerAttachment } from "@/attachments/types";
|
|
||||||
import type { MessagePayload } from "@/components/message-input";
|
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
|
||||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
|
||||||
import type { StreamItem } from "@/types/stream";
|
|
||||||
import { WorkspaceDraftAgentTab } from "./workspace-draft-agent-tab";
|
|
||||||
|
|
||||||
const {
|
|
||||||
createAgentMock,
|
|
||||||
getCheckoutStatusMock,
|
|
||||||
onRuntimeEventMock,
|
|
||||||
latestComposerText,
|
|
||||||
latestComposerDisabled,
|
|
||||||
latestStreamText,
|
|
||||||
onCreatedMock,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
createAgentMock: vi.fn(),
|
|
||||||
getCheckoutStatusMock: vi.fn(),
|
|
||||||
onRuntimeEventMock: vi.fn(() => () => {}),
|
|
||||||
latestComposerText: { current: null as string | null },
|
|
||||||
latestComposerDisabled: { current: null as boolean | null },
|
|
||||||
latestStreamText: { current: null as string | null },
|
|
||||||
onCreatedMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (factory: unknown) =>
|
|
||||||
typeof factory === "function"
|
|
||||||
? factory({
|
|
||||||
colors: {
|
|
||||||
surface0: "#000",
|
|
||||||
surface2: "#222",
|
|
||||||
destructive: "#f00",
|
|
||||||
},
|
|
||||||
spacing: { 2: 8, 3: 12, 4: 16, 6: 24 },
|
|
||||||
borderRadius: { md: 6 },
|
|
||||||
fontSize: { sm: 13 },
|
|
||||||
})
|
|
||||||
: factory,
|
|
||||||
},
|
|
||||||
useUnistyles: () => ({ rt: { breakpoint: "lg" } }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-safe-area-context", () => ({
|
|
||||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
|
||||||
isWeb: true,
|
|
||||||
isNative: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHostRuntimeClient: () => ({
|
|
||||||
createAgent: createAgentMock,
|
|
||||||
getCheckoutStatus: getCheckoutStatusMock,
|
|
||||||
on: onRuntimeEventMock,
|
|
||||||
}),
|
|
||||||
useHostRuntimeIsConnected: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/session-store-hooks", () => ({
|
|
||||||
useWorkspaceExecutionAuthority: () => ({
|
|
||||||
ok: true,
|
|
||||||
authority: {
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
workspaceDirectory: "/repo/.paseo/worktrees/workspace-1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/hooks/use-agent-input-draft", () => ({
|
|
||||||
useAgentInputDraft: () => {
|
|
||||||
const [text, setText] = React.useState("please review this change");
|
|
||||||
const [attachments, setAttachments] = React.useState<ComposerAttachment[]>([]);
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
setText,
|
|
||||||
attachments,
|
|
||||||
setAttachments,
|
|
||||||
cwd: "/repo/.paseo/worktrees/workspace-1",
|
|
||||||
clear: () => {
|
|
||||||
setText("");
|
|
||||||
setAttachments([]);
|
|
||||||
},
|
|
||||||
isHydrated: true,
|
|
||||||
composerState: {
|
|
||||||
providerDefinitions: [{ id: "codex", label: "Codex" }],
|
|
||||||
selectedProvider: "codex",
|
|
||||||
selectedMode: "",
|
|
||||||
modeOptions: [],
|
|
||||||
selectedModel: "gpt-5.4",
|
|
||||||
availableModels: [{ id: "gpt-5.4", label: "GPT-5.4" }],
|
|
||||||
allProviderModels: {},
|
|
||||||
isAllModelsLoading: false,
|
|
||||||
isModelLoading: false,
|
|
||||||
effectiveModelId: "gpt-5.4",
|
|
||||||
effectiveThinkingOptionId: "",
|
|
||||||
featureValues: undefined,
|
|
||||||
statusControls: {
|
|
||||||
providerDefinitions: [{ id: "codex", label: "Codex" }],
|
|
||||||
selectedProvider: "codex",
|
|
||||||
modeOptions: [],
|
|
||||||
selectedMode: "",
|
|
||||||
models: [{ id: "gpt-5.4", label: "GPT-5.4" }],
|
|
||||||
selectedModel: "gpt-5.4",
|
|
||||||
thinkingOptions: [],
|
|
||||||
selectedThinkingOptionId: "",
|
|
||||||
isModelLoading: false,
|
|
||||||
allProviderModels: {},
|
|
||||||
isAllModelsLoading: false,
|
|
||||||
features: [],
|
|
||||||
},
|
|
||||||
commandDraftConfig: undefined,
|
|
||||||
persistFormPreferences: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/utils/encode-images", () => ({
|
|
||||||
encodeImages: vi.fn(async () => []),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/composer-attachments", () => ({
|
|
||||||
splitComposerAttachmentsForSubmit: (attachments: ComposerAttachment[]) => ({
|
|
||||||
images: [],
|
|
||||||
attachments,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/attachments/composer-workspace-attachments", () => ({
|
|
||||||
composerWorkspaceAttachment: {
|
|
||||||
userAttachmentsOnly: (attachments: ComposerAttachment[]) =>
|
|
||||||
attachments.filter(
|
|
||||||
(attachment): attachment is Exclude<ComposerAttachment, { kind: "review" }> =>
|
|
||||||
attachment.kind !== "review",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/composer", () => ({
|
|
||||||
Composer: ({
|
|
||||||
value,
|
|
||||||
isSubmitLoading,
|
|
||||||
}: {
|
|
||||||
onSubmitMessage: (payload: MessagePayload) => Promise<void>;
|
|
||||||
value: string;
|
|
||||||
isSubmitLoading?: boolean;
|
|
||||||
}) => {
|
|
||||||
latestComposerText.current = value;
|
|
||||||
latestComposerDisabled.current = isSubmitLoading ?? false;
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
aria-label="Message agent..."
|
|
||||||
data-testid="composer"
|
|
||||||
disabled={isSubmitLoading}
|
|
||||||
readOnly
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/agent-stream-view", () => ({
|
|
||||||
AgentStreamView: ({ streamItems }: { streamItems: StreamItem[] }) => {
|
|
||||||
latestStreamText.current =
|
|
||||||
streamItems.find((item) => item.kind === "user_message")?.text ?? null;
|
|
||||||
return <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/file-drop-zone", () => ({
|
|
||||||
FileDropZone: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createDeferredPromise<T>() {
|
|
||||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
||||||
let reject!: (reason?: unknown) => void;
|
|
||||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
|
||||||
resolve = nextResolve;
|
|
||||||
reject = nextReject;
|
|
||||||
});
|
|
||||||
return { promise, resolve, reject };
|
|
||||||
}
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLElement | null = null;
|
|
||||||
let queryClient: QueryClient | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("React", React);
|
|
||||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
vi.stubGlobal(
|
|
||||||
"ResizeObserver",
|
|
||||||
class ResizeObserver {
|
|
||||||
observe() {}
|
|
||||||
unobserve() {}
|
|
||||||
disconnect() {}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
createAgentMock.mockReset();
|
|
||||||
getCheckoutStatusMock.mockResolvedValue({
|
|
||||||
cwd: "/repo/.paseo/worktrees/workspace-1",
|
|
||||||
error: null,
|
|
||||||
requestId: "checkout-status-1",
|
|
||||||
isGit: false,
|
|
||||||
isPaseoOwnedWorktree: true,
|
|
||||||
repoRoot: null,
|
|
||||||
currentBranch: null,
|
|
||||||
isDirty: false,
|
|
||||||
baseRef: null,
|
|
||||||
aheadBehind: null,
|
|
||||||
aheadOfOrigin: null,
|
|
||||||
behindOfOrigin: null,
|
|
||||||
hasRemote: false,
|
|
||||||
remoteUrl: null,
|
|
||||||
});
|
|
||||||
onRuntimeEventMock.mockClear();
|
|
||||||
onCreatedMock.mockReset();
|
|
||||||
latestComposerText.current = null;
|
|
||||||
latestComposerDisabled.current = null;
|
|
||||||
latestStreamText.current = null;
|
|
||||||
useCreateFlowStore.getState().clearAll();
|
|
||||||
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root) {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
queryClient?.clear();
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
queryClient = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function flush() {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDraftTab() {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<QueryClientProvider client={queryClient!}>
|
|
||||||
<WorkspaceDraftAgentTab
|
|
||||||
serverId="server"
|
|
||||||
workspaceId="workspace-1"
|
|
||||||
tabId="draft-1"
|
|
||||||
draftId="draft-1"
|
|
||||||
isPaneFocused={true}
|
|
||||||
onCreated={onCreatedMock}
|
|
||||||
onOpenWorkspaceFile={vi.fn()}
|
|
||||||
/>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("WorkspaceDraftAgentTab", () => {
|
|
||||||
it("clears the composer while an auto-submitted new-workspace prompt is shown in the stream", async () => {
|
|
||||||
const createAgent = createDeferredPromise<{ id: string }>();
|
|
||||||
createAgentMock.mockImplementationOnce(async () => await createAgent.promise);
|
|
||||||
useWorkspaceDraftSubmissionStore.getState().setPending({
|
|
||||||
serverId: "server",
|
|
||||||
workspaceId: "workspace-1",
|
|
||||||
draftId: "draft-1",
|
|
||||||
text: "please review this change",
|
|
||||||
attachments: [],
|
|
||||||
cwd: "/repo/.paseo/worktrees/workspace-1",
|
|
||||||
provider: "codex",
|
|
||||||
model: "gpt-5.4",
|
|
||||||
allowEmptyText: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderDraftTab();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(createAgentMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(latestStreamText.current).toBe("please review this change");
|
|
||||||
expect(latestComposerDisabled.current).toBe(true);
|
|
||||||
expect(latestComposerText.current).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -285,18 +285,12 @@ describe("highlightDiffFile", () => {
|
|||||||
|
|
||||||
// First content line: "const foo = 1;"
|
// First content line: "const foo = 1;"
|
||||||
const constLine = hunk.lines[1];
|
const constLine = hunk.lines[1];
|
||||||
expect(constLine.tokens).toBeDefined();
|
expect(constLine.tokens).toContainEqual(
|
||||||
expect(constLine.tokens!.length).toBeGreaterThan(0);
|
expect.objectContaining({ text: "const", style: "keyword" }),
|
||||||
|
);
|
||||||
// Should have a "keyword" token for "const"
|
expect(constLine.tokens).toContainEqual(
|
||||||
const constToken = constLine.tokens!.find((t) => t.text === "const");
|
expect.objectContaining({ text: "1", style: "number" }),
|
||||||
expect(constToken).toBeDefined();
|
);
|
||||||
expect(constToken!.style).toBe("keyword");
|
|
||||||
|
|
||||||
// Should have a "number" token for "1"
|
|
||||||
const numberToken = constLine.tokens!.find((t) => t.text === "1");
|
|
||||||
expect(numberToken).toBeDefined();
|
|
||||||
expect(numberToken!.style).toBe("number");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("highlights both added and removed lines correctly", () => {
|
it("highlights both added and removed lines correctly", () => {
|
||||||
@@ -306,15 +300,15 @@ describe("highlightDiffFile", () => {
|
|||||||
|
|
||||||
// Removed line: "const bar = 2;"
|
// Removed line: "const bar = 2;"
|
||||||
const removedLine = hunk.lines.find((l) => l.type === "remove" && l.content.includes("bar"));
|
const removedLine = hunk.lines.find((l) => l.type === "remove" && l.content.includes("bar"));
|
||||||
expect(removedLine?.tokens).toBeDefined();
|
expect(removedLine?.tokens).toContainEqual(
|
||||||
const removedNumber = removedLine!.tokens!.find((t) => t.text === "2");
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
expect(removedNumber?.style).toBe("number");
|
);
|
||||||
|
|
||||||
// Added line: "const bar = 3;"
|
// Added line: "const bar = 3;"
|
||||||
const addedLine = hunk.lines.find((l) => l.type === "add" && l.content.includes("bar"));
|
const addedLine = hunk.lines.find((l) => l.type === "add" && l.content.includes("bar"));
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
const addedNumber = addedLine!.tokens!.find((t) => t.text === "3");
|
expect.objectContaining({ text: "3", style: "number" }),
|
||||||
expect(addedNumber?.style).toBe("number");
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not modify unsupported file types", () => {
|
it("does not modify unsupported file types", () => {
|
||||||
@@ -351,15 +345,16 @@ describe("highlightDiffFile", () => {
|
|||||||
const highlighted = highlightDiffFile(files[0]);
|
const highlighted = highlightDiffFile(files[0]);
|
||||||
const hunk = highlighted.hunks[0];
|
const hunk = highlighted.hunks[0];
|
||||||
|
|
||||||
const fnLine = hunk.lines[1];
|
expect(hunk.lines[1].tokens).toContainEqual(
|
||||||
expect(fnLine.tokens).toBeDefined();
|
expect.objectContaining({ text: "fn", style: "keyword" }),
|
||||||
expect(fnLine.tokens!.some((t) => t.text === "fn" && t.style === "keyword")).toBe(true);
|
);
|
||||||
|
|
||||||
const addedLine = hunk.lines.find(
|
const addedLine = hunk.lines.find(
|
||||||
(line) => line.type === "add" && line.content.includes("version"),
|
(line) => line.type === "add" && line.content.includes("version"),
|
||||||
);
|
);
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to C code", () => {
|
it("adds syntax highlighting tokens to C code", () => {
|
||||||
@@ -367,15 +362,14 @@ describe("highlightDiffFile", () => {
|
|||||||
const highlighted = highlightDiffFile(files[0]);
|
const highlighted = highlightDiffFile(files[0]);
|
||||||
const hunk = highlighted.hunks[0];
|
const hunk = highlighted.hunks[0];
|
||||||
|
|
||||||
const mainLine = hunk.lines[1];
|
expect(hunk.lines[1].tokens?.length).toBeGreaterThan(0);
|
||||||
expect(mainLine.tokens).toBeDefined();
|
|
||||||
expect(mainLine.tokens!.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const addedLine = hunk.lines.find(
|
const addedLine = hunk.lines.find(
|
||||||
(line) => line.type === "add" && line.content.includes("version"),
|
(line) => line.type === "add" && line.content.includes("version"),
|
||||||
);
|
);
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to Java code", () => {
|
it("adds syntax highlighting tokens to Java code", () => {
|
||||||
@@ -383,15 +377,16 @@ describe("highlightDiffFile", () => {
|
|||||||
const highlighted = highlightDiffFile(files[0]);
|
const highlighted = highlightDiffFile(files[0]);
|
||||||
const hunk = highlighted.hunks[0];
|
const hunk = highlighted.hunks[0];
|
||||||
|
|
||||||
const classLine = hunk.lines[1];
|
expect(hunk.lines[1].tokens).toContainEqual(
|
||||||
expect(classLine.tokens).toBeDefined();
|
expect.objectContaining({ text: "public", style: "keyword" }),
|
||||||
expect(classLine.tokens!.some((t) => t.text === "public" && t.style === "keyword")).toBe(true);
|
);
|
||||||
|
|
||||||
const addedLine = hunk.lines.find(
|
const addedLine = hunk.lines.find(
|
||||||
(line) => line.type === "add" && line.content.includes("version"),
|
(line) => line.type === "add" && line.content.includes("version"),
|
||||||
);
|
);
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to Objective-C file extensions", () => {
|
it("adds syntax highlighting tokens to Objective-C file extensions", () => {
|
||||||
@@ -402,8 +397,9 @@ describe("highlightDiffFile", () => {
|
|||||||
const addedLine = hunk.lines.find(
|
const addedLine = hunk.lines.find(
|
||||||
(line) => line.type === "add" && line.content.includes("version"),
|
(line) => line.type === "add" && line.content.includes("version"),
|
||||||
);
|
);
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to Go code", () => {
|
it("adds syntax highlighting tokens to Go code", () => {
|
||||||
@@ -413,8 +409,9 @@ describe("highlightDiffFile", () => {
|
|||||||
(line) => line.type === "add" && line.content.includes("version"),
|
(line) => line.type === "add" && line.content.includes("version"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to PHP code", () => {
|
it("adds syntax highlighting tokens to PHP code", () => {
|
||||||
@@ -424,8 +421,9 @@ describe("highlightDiffFile", () => {
|
|||||||
(line) => line.type === "add" && line.content.includes("$version"),
|
(line) => line.type === "add" && line.content.includes("$version"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(
|
||||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
expect.objectContaining({ text: "2", style: "number" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to YAML code", () => {
|
it("adds syntax highlighting tokens to YAML code", () => {
|
||||||
@@ -435,8 +433,7 @@ describe("highlightDiffFile", () => {
|
|||||||
(line) => line.type === "add" && line.content.includes("count"),
|
(line) => line.type === "add" && line.content.includes("count"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens?.length).toBeGreaterThan(0);
|
||||||
expect(addedLine!.tokens!.length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds syntax highlighting tokens to XML code", () => {
|
it("adds syntax highlighting tokens to XML code", () => {
|
||||||
@@ -446,8 +443,7 @@ describe("highlightDiffFile", () => {
|
|||||||
(line) => line.type === "add" && line.content.includes("<count>"),
|
(line) => line.type === "add" && line.content.includes("<count>"),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(addedLine?.tokens).toBeDefined();
|
expect(addedLine?.tokens).toContainEqual(expect.objectContaining({ style: "tag" }));
|
||||||
expect(addedLine!.tokens!.some((t) => t.style === "tag")).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -456,7 +452,9 @@ describe("parseAndHighlightDiff", () => {
|
|||||||
const files = parseAndHighlightDiff(SIMPLE_DIFF);
|
const files = parseAndHighlightDiff(SIMPLE_DIFF);
|
||||||
|
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
expect(files[0].hunks[0].lines[1].tokens).toBeDefined();
|
expect(files[0].hunks[0].lines[1].tokens).toContainEqual(
|
||||||
|
expect.objectContaining({ text: "const", style: "keyword" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles multiple files", () => {
|
it("handles multiple files", () => {
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import type {
|
|||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
PullRequestTimelineResponse,
|
PullRequestTimelineResponse,
|
||||||
} from "@server/shared/messages";
|
} from "@server/shared/messages";
|
||||||
import { deriveAvatarColor, formatAge, mapPrPaneData } from "./pr-pane-data";
|
import {
|
||||||
|
deriveAvatarColor,
|
||||||
|
formatAge,
|
||||||
|
getActivityVerb,
|
||||||
|
getStateLabel,
|
||||||
|
mapPrPaneData,
|
||||||
|
} from "./pr-pane-data";
|
||||||
|
|
||||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||||
@@ -335,3 +341,38 @@ describe("formatAge", () => {
|
|||||||
expect(formatAge(now - 365 * 24 * 60 * 60_000, now)).toBe("1y ago");
|
expect(formatAge(now - 365 * 24 * 60 * 60_000, now)).toBe("1y ago");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getStateLabel", () => {
|
||||||
|
it.each([
|
||||||
|
["open", "Open"],
|
||||||
|
["draft", "Draft"],
|
||||||
|
["merged", "Merged"],
|
||||||
|
["closed", "Closed"],
|
||||||
|
] as const)("maps %s → %s", (state, expected) => {
|
||||||
|
expect(getStateLabel(state)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getActivityVerb", () => {
|
||||||
|
it("returns Commented for comment kind", () => {
|
||||||
|
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Approved for approved review", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Requested changes for changes_requested review", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
|
||||||
|
"Requested changes",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Reviewed for a commented review with body (generic case)", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Reviewed when reviewState is undefined", () => {
|
||||||
|
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -225,3 +225,17 @@ function hashLogin(login: string): number {
|
|||||||
}
|
}
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStateLabel(state: PrState): string {
|
||||||
|
if (state === "draft") return "Draft";
|
||||||
|
if (state === "merged") return "Merged";
|
||||||
|
if (state === "closed") return "Closed";
|
||||||
|
return "Open";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
|
||||||
|
if (item.kind === "comment") return "Commented";
|
||||||
|
if (item.reviewState === "approved") return "Approved";
|
||||||
|
if (item.reviewState === "changes_requested") return "Requested changes";
|
||||||
|
return "Reviewed";
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe("configToDraft", () => {
|
|||||||
expect(devRow.commandOriginalKind).toBe("string");
|
expect(devRow.commandOriginalKind).toBe("string");
|
||||||
expect(devRow.type).toBe("long-running");
|
expect(devRow.type).toBe("long-running");
|
||||||
expect(devRow.portText).toBe("3000");
|
expect(devRow.portText).toBe("3000");
|
||||||
expect(devRow.id).toBeTruthy();
|
expect(devRow.id).toMatch(/^script-draft-\d+$/);
|
||||||
expect(buildRow.name).toBe("build");
|
expect(buildRow.name).toBe("build");
|
||||||
expect(buildRow.commandText).toBe("npm\nrun\nbuild");
|
expect(buildRow.commandText).toBe("npm\nrun\nbuild");
|
||||||
expect(buildRow.commandOriginalKind).toBe("array");
|
expect(buildRow.commandOriginalKind).toBe("array");
|
||||||
|
|||||||
Reference in New Issue
Block a user