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 { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
|
||||
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
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 { 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.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||
// reliably for every test that uses this `test` object.
|
||||
const test = base.extend<{ paseoE2ESetup: void }>({
|
||||
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -102,6 +103,11 @@ const test = base.extend<{ paseoE2ESetup: void }>({
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
withWorkspace: async ({ page }, provide) => {
|
||||
const handle = createWithWorkspace(page);
|
||||
await provide(handle.withWorkspace);
|
||||
await handle.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
export { test, expect, type Page };
|
||||
|
||||
@@ -705,6 +705,7 @@ export default async function globalSetup() {
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
process.env.E2E_PASEO_HOME = paseoHome;
|
||||
console.log(
|
||||
`[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 { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
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) => {
|
||||
await page.goto("/");
|
||||
await ensureE2EStorageSeeded(page);
|
||||
};
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
await gotoAppShell(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
if (
|
||||
!(await composer
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false))
|
||||
) {
|
||||
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();
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const entryButton = page
|
||||
.getByText("Add a project", { exact: true })
|
||||
.or(page.getByText("Add project", { exact: true }))
|
||||
.or(page.getByText("New agent", { exact: true }))
|
||||
.first();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||
(await newAgentButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await composer.isVisible().catch(() => false)) ||
|
||||
(await entryButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
if (await addProjectCta.isVisible().catch(() => false)) {
|
||||
await addProjectCta.click();
|
||||
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
||||
await addProjectSidebar.click();
|
||||
} else {
|
||||
await newAgentButton.click();
|
||||
}
|
||||
if (!(await composer.isVisible().catch(() => false))) {
|
||||
await entryButton.click();
|
||||
}
|
||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
};
|
||||
|
||||
export const openSettings = async (page: Page) => {
|
||||
@@ -341,46 +160,6 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
};
|
||||
|
||||
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..." });
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
@@ -392,7 +171,7 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
if (await selectHostLabel.isVisible()) {
|
||||
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 daemonIdOption = page
|
||||
.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 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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/** 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. */
|
||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
@@ -90,11 +76,6 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
|
||||
// ─── 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. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
@@ -119,7 +100,7 @@ export async function clickNewChat(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/** 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();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
@@ -193,6 +174,19 @@ export async function sampleTabsDuringTransition(
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
/** 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 { pathToFileURL } from "node:url";
|
||||
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 {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
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 { clickNewChat, clickTerminal } from "./launcher";
|
||||
import { clickNewChat, clickNewTerminal } from "./launcher";
|
||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||
|
||||
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> {
|
||||
await clickTerminal(page);
|
||||
await clickNewTerminal(page);
|
||||
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 { gotoAppShell } from "./app";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { switchWorkspaceViaSidebar } from "./workspace-ui";
|
||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
|
||||
interface WorkspaceSetupDaemonClient {
|
||||
@@ -315,6 +316,30 @@ export async function fetchWorkspaceById(
|
||||
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(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
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(
|
||||
page: Page,
|
||||
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> {
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
assertNewChatTileVisible,
|
||||
assertTerminalTileVisible,
|
||||
assertSingleNewTabButton,
|
||||
clickNewTabButton,
|
||||
pressNewTabShortcut,
|
||||
clickNewChat,
|
||||
clickTerminal,
|
||||
clickNewTerminal,
|
||||
countTabsOfKind,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
sampleTabsDuringTransition,
|
||||
terminalSurfaceLocator,
|
||||
} from "./helpers/launcher";
|
||||
import { expectComposerVisible, composerLocator } from "./helpers/composer";
|
||||
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
setupDeterministicPrompt,
|
||||
@@ -50,9 +52,7 @@ test.describe("Tab creation", () => {
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
|
||||
// Should show the composer directly (no launcher panel)
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||
await expectComposerVisible(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 }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await clickNewChat(page);
|
||||
|
||||
// Draft composer should appear (the agent message input)
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||
@@ -92,11 +90,9 @@ test.describe("Tab creation", () => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickTerminal(page);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
// Terminal surface should appear
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||
@@ -141,19 +137,18 @@ test.describe("Terminal title propagation", () => {
|
||||
try {
|
||||
// Navigate to workspace and open a terminal
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickTerminal(page);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await terminalSurfaceLocator(page).click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Send OSC 0 (set window title) escape sequence
|
||||
const testTitle = `E2E-Title-${Date.now()}`;
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
||||
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
// Wait for the tab to reflect the new title
|
||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||
@@ -171,24 +166,24 @@ test.describe("Terminal title propagation", () => {
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickTerminal(page);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await terminalSurfaceLocator(page).click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Fire many rapid title changes — only the last should stick
|
||||
const finalTitle = `Final-${Date.now()}`;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
||||
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
|
||||
delay: 0,
|
||||
});
|
||||
}
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
||||
await terminalSurfaceLocator(page).pressSequentially(
|
||||
`printf '\\033]0;${finalTitle}\\007'\n`,
|
||||
{ delay: 0 },
|
||||
);
|
||||
|
||||
// The tab should eventually settle on the final title
|
||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||
@@ -228,11 +223,10 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickTerminal(page),
|
||||
terminal.first(),
|
||||
() => clickNewTerminal(page),
|
||||
terminalSurfaceLocator(page),
|
||||
20_000,
|
||||
);
|
||||
|
||||
@@ -245,9 +239,12 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
|
||||
const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composerLocator(page),
|
||||
10_000,
|
||||
);
|
||||
|
||||
// Draft creation is fully in-memory — should be fast
|
||||
// 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 { 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 {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
@@ -18,17 +32,6 @@ function getSeededDaemonPort(): string {
|
||||
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("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||
page,
|
||||
@@ -38,28 +41,13 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(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 expectHostLabelHeader(page);
|
||||
|
||||
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
|
||||
const connectionsCard = page.getByTestId("host-page-connections-card");
|
||||
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();
|
||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostConnectionsCard(page, port);
|
||||
await expectHostInjectMcpCard(page);
|
||||
await expectHostActionCards(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 openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("host-page-label-edit-button").click();
|
||||
|
||||
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();
|
||||
await expectHostLabelDisplayed(page);
|
||||
await clickEditHostLabel(page);
|
||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||
});
|
||||
|
||||
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 openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||
// Pair-device now lives behind a row that only the local host sees
|
||||
// (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);
|
||||
await expectHostNoLocalOnlyRows(page);
|
||||
});
|
||||
|
||||
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
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();
|
||||
await expectRetiredSidebarSectionsAbsent(page);
|
||||
});
|
||||
|
||||
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 page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
await expectHostLabelHeader(page);
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
await expectHostPageVisible(page, serverId);
|
||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(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 openSettings(page);
|
||||
|
||||
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();
|
||||
await expectLocalHostEntryFirst(page, serverId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,62 +1,60 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getServerId(): 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;
|
||||
}
|
||||
|
||||
async function clickSidebarSection(page: Page, label: string) {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await sidebar.getByRole("button", { name: label, exact: true }).click();
|
||||
}
|
||||
import {
|
||||
openSettingsSection,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
selectHostConnectionType,
|
||||
toggleHostAdvanced,
|
||||
openCompactSettings,
|
||||
expectCompactSettingsList,
|
||||
expectSettingsSidebarVisible,
|
||||
expectSettingsSidebarHidden,
|
||||
expectSettingsSidebarSections,
|
||||
goBackInSettings,
|
||||
expectSettingsBackButton,
|
||||
clickSettingsBackToWorkspace,
|
||||
verifyLegacyHostSettingsRedirect,
|
||||
openCompactSettingsHost,
|
||||
expectAddHostMethodOptions,
|
||||
fillDirectHostUri,
|
||||
expectDirectHostFormValues,
|
||||
expectDirectHostSslEnabled,
|
||||
expectDirectHostUriValue,
|
||||
expectDirectHostUriHidden,
|
||||
expectDiagnosticsContent,
|
||||
expectAboutContent,
|
||||
expectGeneralContent,
|
||||
} from "./helpers/settings";
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await clickSidebarSection(page, "Diagnostics");
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectSettingsHeader(page, "Diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
|
||||
await clickSidebarSection(page, "About");
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
|
||||
await openSettingsSection(page, "about");
|
||||
await expectSettingsHeader(page, "About");
|
||||
await expectAboutContent(page);
|
||||
|
||||
await clickSidebarSection(page, "General");
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
|
||||
await openSettingsSection(page, "general");
|
||||
await expectSettingsHeader(page, "General");
|
||||
await expectGeneralContent(page);
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
});
|
||||
|
||||
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await page.getByTestId("settings-add-host").click();
|
||||
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();
|
||||
await openAddHostFlow(page);
|
||||
await expectAddHostMethodOptions(page);
|
||||
});
|
||||
|
||||
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 openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
|
||||
await page.getByTestId("settings-add-host").click();
|
||||
await page.getByRole("button", { name: "Direct connection" }).click();
|
||||
await toggleHostAdvanced(page);
|
||||
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 page
|
||||
.getByTestId("direct-host-uri-input")
|
||||
.fill("tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
await expectDirectHostFormValues(page, {
|
||||
host: "example.paseo.test",
|
||||
port: "7443",
|
||||
password: "shared-secret",
|
||||
});
|
||||
await expectDirectHostSslEnabled(page);
|
||||
await expectDirectHostUriHidden(page);
|
||||
|
||||
await expect(page.getByTestId("direct-host-input")).toHaveValue("example.paseo.test");
|
||||
await expect(page.getByTestId("direct-port-input")).toHaveValue("7443");
|
||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||
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(
|
||||
await toggleHostAdvanced(page);
|
||||
await expectDirectHostUriValue(
|
||||
page,
|
||||
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
|
||||
);
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
||||
await toggleHostAdvanced(page);
|
||||
await expectDirectHostUriHidden(page);
|
||||
});
|
||||
|
||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const backRow = page.getByTestId("settings-back-to-workspace");
|
||||
await expect(backRow).toBeVisible();
|
||||
|
||||
await backRow.click();
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
});
|
||||
@@ -103,113 +97,57 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
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 }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
|
||||
// Sidebar rows are present.
|
||||
await expect(
|
||||
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();
|
||||
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
|
||||
await expectCompactSettingsList(page);
|
||||
|
||||
// Section detail content is NOT rendered at the root.
|
||||
await expectCompactSettingsRootList(page);
|
||||
|
||||
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
|
||||
await expect(rootBackButton).toBeVisible();
|
||||
await rootBackButton.click();
|
||||
await expectSettingsBackButton(page);
|
||||
await goBackInSettings(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
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
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "Diagnostics", exact: true })
|
||||
.click();
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
// Sidebar is no longer visible — we are on a detail screen.
|
||||
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
|
||||
// only visible instances.)
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await expectSettingsBackButton(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
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "About", exact: true })
|
||||
.click();
|
||||
await openSettingsSection(page, "about");
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expectCompactSettingsRootList(page);
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
await expectSettingsBackButton(page);
|
||||
});
|
||||
|
||||
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openCompactSettingsRoot(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
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);
|
||||
await openCompactSettingsHost(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
});
|
||||
|
||||
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openCompactSettingsRoot(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await openCompactSettingsHost(page);
|
||||
await goBackInSettings(page);
|
||||
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 { expect, test, type Page } from "./fixtures";
|
||||
import { expect, test } from "./fixtures";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
openWorkspaceWithAgents,
|
||||
} from "./helpers/archive-tab";
|
||||
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
|
||||
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
function getServerId(): string {
|
||||
@@ -17,12 +17,15 @@ function getServerId(): string {
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function pressSettingsToggleShortcut(page: Page) {
|
||||
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
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
|
||||
.poll(async () => {
|
||||
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: {
|
||||
page: Page;
|
||||
page: import("@playwright/test").Page;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}) {
|
||||
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
|
||||
await input.page.goto(
|
||||
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
|
||||
);
|
||||
@@ -49,10 +51,7 @@ async function openAgentRouteAndExpectFocused(input: {
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await waitForTabBar(input.page);
|
||||
await expect(
|
||||
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
|
||||
await expectAgentTabActive(input.page, input.agentId);
|
||||
}
|
||||
|
||||
test.describe("Settings toggle tab regression", () => {
|
||||
@@ -79,13 +78,7 @@ test.describe("Settings toggle tab regression", () => {
|
||||
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await waitForTabBar(page);
|
||||
|
||||
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 expectAgentTabActive(page, secondAgent.id);
|
||||
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
@@ -98,19 +91,11 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForTabBar(page);
|
||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
|
||||
await page.reload();
|
||||
await waitForTabBar(page);
|
||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||
@@ -152,14 +137,10 @@ test.describe("Settings toggle tab regression", () => {
|
||||
agentId: secondAgent.id,
|
||||
});
|
||||
|
||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await page.reload();
|
||||
await waitForTabBar(page);
|
||||
await expect(
|
||||
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
}
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
|
||||
@@ -1,129 +1,45 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
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 { test } from "./fixtures";
|
||||
import { clickNewTerminal } from "./helpers/launcher";
|
||||
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 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);
|
||||
}
|
||||
expectTerminalSurfaceVisible,
|
||||
focusTerminalSurface,
|
||||
typeInTerminal,
|
||||
setupDeterministicPrompt,
|
||||
waitForTerminalContent,
|
||||
} from "./helpers/terminal-perf";
|
||||
|
||||
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);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("workspace-cwd-main-");
|
||||
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(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;
|
||||
|
||||
// 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();
|
||||
}
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("workspace-cwd-worktree-");
|
||||
const resolvedTmp = realpathSync("/tmp");
|
||||
const worktreePath = path.join(
|
||||
resolvedTmp,
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `workspace-cwd-${Date.now()}`;
|
||||
let worktreeCreated = false;
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(page);
|
||||
|
||||
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;
|
||||
|
||||
// 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();
|
||||
}
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
import { execSync } from "node:child_process";
|
||||
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 { test } from "./fixtures";
|
||||
import {
|
||||
createAgentChatFromLauncher,
|
||||
createStandaloneTerminalFromLauncher,
|
||||
expectTerminalCwd,
|
||||
} 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", () => {
|
||||
// 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("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);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-main-chat-");
|
||||
|
||||
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();
|
||||
}
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
|
||||
await workspace.navigateTo();
|
||||
await createAgentChatFromLauncher(page);
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-main-shell-");
|
||||
|
||||
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();
|
||||
}
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-wt-chat-");
|
||||
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();
|
||||
}
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
|
||||
await workspace.navigateTo();
|
||||
await createAgentChatFromLauncher(page);
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-wt-shell-");
|
||||
const resolvedTmp = realpathSync("/tmp");
|
||||
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();
|
||||
}
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
@@ -15,17 +15,26 @@ import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
getVisibleWorkspaceAgentTabIds,
|
||||
expectOnlyWorkspaceAgentTabsVisible,
|
||||
waitForWorkspaceTabsVisible,
|
||||
expectWorkspaceTabsAbsent,
|
||||
} from "./helpers/workspace-tabs";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
expectWorkspaceHeader,
|
||||
expectWorkspaceHeaderAbsent,
|
||||
expectMenuButtonVisible,
|
||||
expectHostConnectingOrOffline,
|
||||
expectReconnectingToastVisible,
|
||||
expectReconnectingToastGone,
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForSidebarHydration,
|
||||
workspaceDeckEntryLocator,
|
||||
expectWorkspaceDeckEntryCount,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
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) {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
@@ -159,35 +172,31 @@ test.describe("Workspace navigation regression", () => {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: workspace.projectDisplayName,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
|
||||
await daemonGate.drop();
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectReconnectingToastVisible(page);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: workspace.projectDisplayName,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectComposerVisible(page);
|
||||
await expectNoLoadingPane(page);
|
||||
|
||||
const monitorReconnect = expectNoLoadingWorkspacePane(page, {
|
||||
label: "host reconnect",
|
||||
});
|
||||
daemonGate.restore();
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectReconnectingToastGone(page);
|
||||
await monitorReconnect;
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: workspace.projectDisplayName,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectComposerVisible(page);
|
||||
} finally {
|
||||
daemonGate.restore();
|
||||
for (const agentId of agentIds) {
|
||||
@@ -222,16 +231,11 @@ test.describe("Workspace navigation regression", () => {
|
||||
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
|
||||
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 expectHostConnectingOrOffline(page);
|
||||
await expectMenuButtonVisible(page);
|
||||
await expectWorkspaceHeaderAbsent(page);
|
||||
await expectWorkspaceTabsAbsent(page);
|
||||
await openSettings(page);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
});
|
||||
|
||||
@@ -270,6 +274,13 @@ test.describe("Workspace navigation regression", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
|
||||
const firstDeckEntry = workspaceDeckEntryLocator(page, serverId, firstWorkspace.workspaceId);
|
||||
const secondDeckEntry = workspaceDeckEntryLocator(
|
||||
page,
|
||||
serverId,
|
||||
secondWorkspace.workspaceId,
|
||||
);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
@@ -300,13 +311,6 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||
`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 switchWorkspaceViaSidebar({
|
||||
@@ -342,7 +346,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expect(firstDeckEntry).toBeAttached();
|
||||
await expect(firstDeckEntry).toBeHidden();
|
||||
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(
|
||||
({ agentId, serverId: targetServerId }) => {
|
||||
@@ -371,7 +375,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||
await expect(firstDeckEntry).toBeAttached();
|
||||
await expect(firstDeckEntry).toBeHidden();
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
@@ -385,7 +389,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
await expect(secondDeckEntry).toBeAttached();
|
||||
await expect(secondDeckEntry).toBeHidden();
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
||||
import { clickNewTerminal } from "./helpers/launcher";
|
||||
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceThroughDaemon,
|
||||
findWorktreeWorkspaceForProject,
|
||||
navigateToWorkspaceViaSidebar,
|
||||
openHomeWithProject,
|
||||
} 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.configure({ retries: 1 });
|
||||
|
||||
@@ -48,7 +32,6 @@ test.describe("Workspace setup runtime authority", () => {
|
||||
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
|
||||
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
|
||||
|
||||
// Navigate to the workspace via sidebar
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
@@ -86,10 +69,8 @@ test.describe("Workspace setup runtime authority", () => {
|
||||
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 clickNewTerminal(page);
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
|
||||
// Verify terminal is listed under the worktree directory, not the original repo
|
||||
await expect
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
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 {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceThroughDaemon,
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
navigateToWorkspaceViaSidebar,
|
||||
openWorkspaceScriptsMenu,
|
||||
startWorkspaceScriptFromMenu,
|
||||
closeWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
waitForWorkspaceSetupProgress,
|
||||
} 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 {
|
||||
startWorkspaceScript(
|
||||
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("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
@@ -100,31 +93,15 @@ test.describe("Workspace setup streaming", () => {
|
||||
});
|
||||
await completed;
|
||||
|
||||
// Navigate to workspace and verify it's usable
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await openFileExplorer(page);
|
||||
await expectExplorerEntryVisible(page, "README.md");
|
||||
await expectExplorerEntryVisible(page, "src");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
@@ -273,33 +250,14 @@ test.describe("Workspace setup streaming", () => {
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||
await page.getByTestId("workspace-scripts-button").click();
|
||||
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("workspace-scripts-start-web").click();
|
||||
await page.getByTestId("workspace-scripts-menu-backdrop").click();
|
||||
|
||||
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||
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");
|
||||
await expectNoTerminalTabs(page);
|
||||
await openWorkspaceScriptsMenu(page);
|
||||
await startWorkspaceScriptFromMenu(page, "web");
|
||||
await closeWorkspaceScriptsMenu(page);
|
||||
await clickFirstTerminalTab(page);
|
||||
await expectTerminalSurfaceVisible(page, { timeout: 10_000 });
|
||||
await waitForTerminalAttached(page);
|
||||
await expectFirstTerminalTabContains(page, "web");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
|
||||
@@ -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 StreamViewportHandle,
|
||||
} from "./agent-stream-render-strategy";
|
||||
import {
|
||||
getAssistantBlockSpacing,
|
||||
isSameAssistantBlockGroup,
|
||||
resolveInlineWorkingIndicatorItemId,
|
||||
} from "./agent-stream-view-data";
|
||||
import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
@@ -92,43 +97,6 @@ const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
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 {
|
||||
aboveItem?: StreamItem | null;
|
||||
belowItem?: StreamItem | null;
|
||||
@@ -301,25 +269,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [isMobile, streamHead, streamItems]);
|
||||
const inlineWorkingIndicatorItemId = useMemo(() => {
|
||||
if (agent.status !== "running") {
|
||||
return null;
|
||||
}
|
||||
const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => {
|
||||
if (item.kind !== "assistant_message") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
}) === undefined
|
||||
);
|
||||
});
|
||||
return footerItem?.id ?? null;
|
||||
}, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]);
|
||||
const inlineWorkingIndicatorItemId = useMemo(
|
||||
() =>
|
||||
resolveInlineWorkingIndicatorItemId(
|
||||
agent.status,
|
||||
baseRenderModel.segments.liveHead,
|
||||
streamRenderStrategy,
|
||||
),
|
||||
[agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy],
|
||||
);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
|
||||
@@ -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 { isWeb } from "@/constants/platform";
|
||||
import { useComposerHeightMirror } from "./composer-height-mirror";
|
||||
import { computeCanStartDictation } from "./message-input-state";
|
||||
|
||||
export type ImageAttachment = AttachmentMetadata;
|
||||
|
||||
@@ -968,19 +969,6 @@ function computeSendableContent(input: SendableContentInput): SendableContentOut
|
||||
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(
|
||||
isReadyForDictation: boolean | undefined,
|
||||
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,
|
||||
} from "lucide-react-native";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
|
||||
import type {
|
||||
CheckStatus,
|
||||
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 {
|
||||
if (state === "open") return theme.colors.statusSuccess;
|
||||
if (state === "draft") return theme.colors.foregroundMuted;
|
||||
@@ -304,13 +298,6 @@ function getStateIcon(state: PrState) {
|
||||
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) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
|
||||
@@ -99,6 +99,7 @@ export function Switch({
|
||||
hitSlop={8}
|
||||
accessibilityRole="switch"
|
||||
accessibilityState={accessibilityState}
|
||||
aria-checked={value}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
style={pressableStyle}
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("desktop attachment store", () => {
|
||||
await store.garbageCollect({ referencedIds: new Set(["att_3"]) });
|
||||
|
||||
expect(readDesktopFileBase64Mock).toHaveBeenCalledWith("/managed/att_3.jpg");
|
||||
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalled();
|
||||
expect(resolveDesktopPreviewUrlMock).toHaveBeenCalledWith(attachment);
|
||||
expect(releaseDesktopPreviewUrlMock).toHaveBeenCalledWith({ url: "blob:test" });
|
||||
expect(deleteDesktopAttachmentFileMock).toHaveBeenCalledWith({
|
||||
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,
|
||||
stopDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { executeDaemonManagementToggle } from "@/desktop/daemon/daemon-management-toggle";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
@@ -39,72 +40,42 @@ function useDaemonManagementToggle(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.manageBuiltInDaemon) {
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
void updateSettings({ manageBuiltInDaemon: true })
|
||||
.then(() => startDesktopDaemon())
|
||||
.then((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
refetch();
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||
Alert.alert("Error", "Unable to update built-in daemon management.");
|
||||
})
|
||||
.finally(() => {
|
||||
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,
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
void executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, {
|
||||
confirm: () =>
|
||||
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,
|
||||
}),
|
||||
persistSettings: (next) => updateSettings(next) as Promise<void>,
|
||||
startDaemon: startDesktopDaemon,
|
||||
stopDaemon: stopDesktopDaemon,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
.then((result) => {
|
||||
if (result.kind === "cancelled") {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
|
||||
void updateSettings({ manageBuiltInDaemon: false })
|
||||
.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);
|
||||
});
|
||||
if (result.newStatus) {
|
||||
setStatus(result.newStatus);
|
||||
}
|
||||
refetch();
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to open built-in daemon pause confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
AgentMode,
|
||||
@@ -13,49 +13,26 @@ import {
|
||||
useFormPreferences,
|
||||
mergeProviderPreferences,
|
||||
type FormPreferences,
|
||||
type ProviderPreferences,
|
||||
} 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 interface FormInitialValues {
|
||||
serverId?: string | null;
|
||||
provider?: AgentProvider;
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
workingDir?: string;
|
||||
}
|
||||
export type { FormInitialValues } from "./resolve-agent-form";
|
||||
|
||||
// Tracks which fields the user has explicitly modified in this session
|
||||
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 {
|
||||
export interface UseAgentFormStateOptions {
|
||||
initialServerId?: string | null;
|
||||
initialValues?: FormInitialValues;
|
||||
isVisible?: boolean;
|
||||
@@ -97,316 +74,6 @@ export interface UseAgentFormStateResult {
|
||||
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: {
|
||||
isVisible: boolean;
|
||||
isCreateFlow: boolean;
|
||||
@@ -434,59 +101,6 @@ function shouldAutoSelectServerId(input: {
|
||||
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: {
|
||||
selectedEntry: ProviderSnapshotEntry | null;
|
||||
provider: AgentProvider | null;
|
||||
@@ -552,34 +166,35 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
|
||||
const daemons = useHosts();
|
||||
|
||||
// Build a set of valid server IDs for preference validation
|
||||
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
||||
|
||||
// Track which fields the user has explicitly modified
|
||||
const [userModified, setUserModified] = useState<UserModifiedFields>(INITIAL_USER_MODIFIED);
|
||||
const [{ form: formState, userModified }, dispatch] = useReducer(
|
||||
resolveAgentForm,
|
||||
initialServerId,
|
||||
(serverId) => ({
|
||||
form: {
|
||||
serverId,
|
||||
provider: null,
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
userModified: INITIAL_USER_MODIFIED,
|
||||
}),
|
||||
);
|
||||
|
||||
// Form state
|
||||
const [formState, setFormState] = useState<FormState>(() => ({
|
||||
serverId: initialServerId,
|
||||
provider: null,
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
}));
|
||||
const formStateRef = useRef(formState);
|
||||
const reducerStateRef = useRef({ form: formState, userModified });
|
||||
useEffect(() => {
|
||||
formStateRef.current = formState;
|
||||
}, [formState]);
|
||||
reducerStateRef.current = { form: formState, userModified };
|
||||
}, [formState, userModified]);
|
||||
|
||||
// Track if we've done initial resolution (to avoid flickering)
|
||||
const hasResolvedRef = useRef(false);
|
||||
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
||||
|
||||
// Reset user modifications when form becomes invisible
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setUserModified(INITIAL_USER_MODIFIED);
|
||||
dispatch({ type: "RESET" });
|
||||
hasResolvedRef.current = false;
|
||||
hydrationPreferencesRef.current = null;
|
||||
}
|
||||
@@ -644,12 +259,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
const modeOptions = snapshotSelectedProviderModes;
|
||||
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||
|
||||
// Combine initialValues with initialServerId for resolution
|
||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||
return combineInitialValues(initialValues, initialServerId);
|
||||
}, [initialValues, initialServerId]);
|
||||
const combinedInitialValues = useMemo(
|
||||
() => combineInitialValues(initialValues, initialServerId),
|
||||
[initialValues, initialServerId],
|
||||
);
|
||||
|
||||
// Resolve form state when data sources change
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isCreateFlow) {
|
||||
return;
|
||||
@@ -664,19 +278,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}
|
||||
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
||||
|
||||
const resolved = resolveFormState(
|
||||
combinedInitialValues,
|
||||
hydrationPreferences,
|
||||
dispatch({
|
||||
type: "RESOLVE",
|
||||
initialValues: combinedInitialValues,
|
||||
preferences: hydrationPreferences,
|
||||
availableModels,
|
||||
userModified,
|
||||
formStateRef.current,
|
||||
validServerIds,
|
||||
snapshotResolvableProviderDefinitionMap,
|
||||
);
|
||||
|
||||
if (hasFormStateChanged(formStateRef.current, resolved)) {
|
||||
setFormState(resolved);
|
||||
}
|
||||
allowedProviderMap: snapshotResolvableProviderDefinitionMap,
|
||||
});
|
||||
|
||||
hasResolvedRef.current = true;
|
||||
}, [
|
||||
@@ -686,15 +294,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
combinedInitialValues,
|
||||
preferences,
|
||||
availableModels,
|
||||
userModified,
|
||||
validServerIds,
|
||||
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("|");
|
||||
useEffect(() => {
|
||||
const canAutoSelectServerId = shouldAutoSelectServerId({
|
||||
@@ -704,14 +306,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
hasResolved: hasResolvedRef.current,
|
||||
userModifiedServerId: userModified.serverId,
|
||||
initialServerId: combinedInitialValues?.serverId,
|
||||
currentServerId: formStateRef.current.serverId,
|
||||
currentServerId: reducerStateRef.current.form.serverId,
|
||||
});
|
||||
if (!canAutoSelectServerId) return;
|
||||
|
||||
const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null;
|
||||
if (!candidate) return;
|
||||
|
||||
setFormState((prev) => (prev.serverId ? prev : { ...prev, serverId: candidate }));
|
||||
dispatch({ type: "AUTO_SELECT_SERVER", candidateServerId: candidate });
|
||||
}, [
|
||||
combinedInitialValues?.serverId,
|
||||
isCreateFlow,
|
||||
@@ -723,10 +325,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
validServerIds,
|
||||
]);
|
||||
|
||||
// User setters - mark fields as modified and persist to preferences
|
||||
const setSelectedServerIdFromUser = useCallback((value: string | null) => {
|
||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
||||
setUserModified((prev) => ({ ...prev, serverId: true }));
|
||||
dispatch({ type: "SET_SERVER_ID_FROM_USER", value });
|
||||
}, []);
|
||||
|
||||
const setProviderFromUser = useCallback(
|
||||
@@ -738,24 +338,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||
|
||||
const nextModelId = pickNextModelForProvider({ providerModels, providerPrefs });
|
||||
const nextModeId = pickNextModeForProvider({ providerDef, providerPrefs });
|
||||
const nextThinkingOptionId = pickNextThinkingOptionForProvider({
|
||||
providerModels,
|
||||
providerPrefs,
|
||||
modelId: nextModelId,
|
||||
});
|
||||
|
||||
setUserModified((prev) => ({ ...prev, provider: true }));
|
||||
void updatePreferences({ provider });
|
||||
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
dispatch({
|
||||
type: "SET_PROVIDER_FROM_USER",
|
||||
provider,
|
||||
modeId: nextModeId,
|
||||
model: nextModelId,
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
}));
|
||||
providerModels,
|
||||
providerDef,
|
||||
providerPrefs,
|
||||
});
|
||||
void updatePreferences({ provider });
|
||||
},
|
||||
[
|
||||
allProviderModels,
|
||||
@@ -774,20 +364,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||
const nextThinkingOptionId = resolveThinkingOptionId({
|
||||
availableModels: providerModels,
|
||||
modelId: nextModelId,
|
||||
requestedThinkingOptionId: "",
|
||||
});
|
||||
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
dispatch({
|
||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||
provider,
|
||||
model: nextModelId,
|
||||
modeId: providerDef?.defaultModeId ?? "",
|
||||
thinkingOptionId: nextThinkingOptionId,
|
||||
}));
|
||||
setUserModified((prev) => ({ ...prev, provider: true, model: true }));
|
||||
modelId,
|
||||
providerDef,
|
||||
providerModels,
|
||||
});
|
||||
void updatePreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
@@ -803,9 +387,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
(modeId: string) => {
|
||||
setFormState((prev) => ({ ...prev, modeId }));
|
||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
||||
const provider = formStateRef.current.provider;
|
||||
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||
const provider = reducerStateRef.current.form.provider;
|
||||
if (provider) {
|
||||
void updatePreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
@@ -823,23 +406,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||
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;
|
||||
dispatch({ type: "SET_MODEL_FROM_USER", modelId, availableModels });
|
||||
const provider = reducerStateRef.current.form.provider;
|
||||
if (provider) {
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||
void updatePreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
@@ -851,15 +422,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
);
|
||||
}
|
||||
},
|
||||
[availableModels, updatePreferences, userModified.thinkingOptionId],
|
||||
[availableModels, updatePreferences],
|
||||
);
|
||||
|
||||
const setThinkingOptionFromUser = useCallback(
|
||||
(thinkingOptionId: string) => {
|
||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
||||
const provider = formStateRef.current.provider;
|
||||
const modelId = formStateRef.current.model;
|
||||
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
||||
const { provider, model: modelId } = reducerStateRef.current.form;
|
||||
if (provider && modelId) {
|
||||
void updatePreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
@@ -878,16 +447,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
);
|
||||
|
||||
const setWorkingDir = useCallback((value: string) => {
|
||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||
dispatch({ type: "SET_WORKING_DIR", value });
|
||||
}, []);
|
||||
|
||||
const setWorkingDirFromUser = useCallback((value: string) => {
|
||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
||||
dispatch({ type: "SET_WORKING_DIR_FROM_USER", value });
|
||||
}, []);
|
||||
|
||||
const setSelectedServerId = useCallback((value: string | null) => {
|
||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
||||
dispatch({ type: "SET_SERVER_ID", value });
|
||||
}, []);
|
||||
|
||||
const refreshProviderModels = useCallback(() => {
|
||||
@@ -895,7 +463,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, [refreshSnapshot]);
|
||||
|
||||
const refetchProviderModelsIfStale = useCallback(() => {
|
||||
refetchSnapshotIfStale(formStateRef.current.provider);
|
||||
refetchSnapshotIfStale(reducerStateRef.current.form.provider);
|
||||
}, [refetchSnapshotIfStale]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
@@ -993,15 +561,4 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
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", () => ({
|
||||
default: {
|
||||
getItem: async () => null,
|
||||
setItem: async () => undefined,
|
||||
removeItem: async () => undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
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);
|
||||
},
|
||||
},
|
||||
function makeStorage() {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: async (key: string) => map.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => {
|
||||
map.set(key, value);
|
||||
},
|
||||
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("__private__.resolveDraftKey", () => {
|
||||
it("returns an object draft key string unchanged", () => {
|
||||
expect(
|
||||
__private__.resolveDraftKey({
|
||||
draftKey: "draft:key",
|
||||
selectedServerId: "host-1",
|
||||
}),
|
||||
).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("resolveEffectiveComposerModelId", () => {
|
||||
it("returns the selected model trimmed", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: " gpt-5.4-mini ",
|
||||
availableModels: [],
|
||||
}),
|
||||
).toBe("gpt-5.4-mini");
|
||||
});
|
||||
|
||||
describe("__private__.resolveEffectiveComposerModelId", () => {
|
||||
const models = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-mini",
|
||||
label: "gpt-5.4-mini",
|
||||
},
|
||||
];
|
||||
it("returns empty string when no model selected", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: "",
|
||||
availableModels: [],
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the selected model when present", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerModelId({
|
||||
selectedModel: "gpt-5.4-mini",
|
||||
availableModels: models,
|
||||
}),
|
||||
).toBe("gpt-5.4-mini");
|
||||
});
|
||||
describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
const models = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("returns empty string when no model selected", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerModelId({
|
||||
selectedModel: "",
|
||||
availableModels: models,
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
it("prefers the selected thinking option when present", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "medium",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
).toBe("medium");
|
||||
});
|
||||
|
||||
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
|
||||
const models = [
|
||||
{
|
||||
it("falls back to the model default thinking option", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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");
|
||||
});
|
||||
cwd: " ",
|
||||
modeOptions: [],
|
||||
selectedMode: "",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("__private__.buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
__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({
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
selectedMode: "auto",
|
||||
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,
|
||||
} from "@/hooks/use-agent-form-state";
|
||||
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 type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
type AttachmentUpdater =
|
||||
| UserComposerAttachment[]
|
||||
@@ -24,12 +32,6 @@ interface AgentInputDraftComposerOptions {
|
||||
lockedWorkingDir?: string;
|
||||
}
|
||||
|
||||
interface DraftKeyContext {
|
||||
selectedServerId: string | null;
|
||||
}
|
||||
|
||||
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
|
||||
|
||||
interface UseAgentInputDraftInput {
|
||||
draftKey: DraftKeyInput;
|
||||
initialCwd?: string;
|
||||
@@ -57,121 +59,6 @@ interface AgentInputDraft {
|
||||
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 {
|
||||
const composerOptions = input.composer ?? null;
|
||||
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 { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
buildSettingsRoute,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import {
|
||||
type MessageInputKeyboardActionKind,
|
||||
type KeyboardShortcutPayload,
|
||||
} from "@/keyboard/actions";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import {
|
||||
type ChordState,
|
||||
@@ -20,26 +11,23 @@ import {
|
||||
buildEffectiveBindings,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import {
|
||||
routeKeyboardShortcut,
|
||||
type ShortcutAction,
|
||||
type ShortcutCallbackName,
|
||||
} from "@/keyboard/route-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { getRelativeSidebarShortcutTarget } from "@/utils/sidebar-shortcuts";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
import {
|
||||
getLastNavigationWorkspaceRouteSelection,
|
||||
getNavigationActiveWorkspaceSelection,
|
||||
} 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({
|
||||
enabled,
|
||||
isMobile,
|
||||
@@ -82,241 +70,57 @@ export function useKeyboardShortcuts({
|
||||
return true;
|
||||
};
|
||||
|
||||
const navigateToWorkspaceShortcut = (index: number): boolean => {
|
||||
const state = useKeyboardShortcutsStore.getState();
|
||||
const target = state.sidebarShortcutWorkspaceTargets[index - 1] ?? null;
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
navigateToWorkspace(target.serverId, target.workspaceId, { currentPathname: pathname });
|
||||
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 captureCommandCenterFocusRestore = (event: KeyboardEvent) => {
|
||||
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);
|
||||
};
|
||||
|
||||
const openProjectPicker = (): boolean => {
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
const callbacksByName: Record<ShortcutCallbackName, (() => void) | undefined> = {
|
||||
"toggle-agent-list": toggleAgentList,
|
||||
"toggle-both-sidebars": toggleBothSidebars,
|
||||
"toggle-focus-mode": toggleFocusMode,
|
||||
"cycle-theme": cycleTheme,
|
||||
};
|
||||
|
||||
const dispatchMessageInputAction = (kind: MessageInputKeyboardActionKind): boolean => {
|
||||
switch (kind) {
|
||||
case "focus":
|
||||
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:
|
||||
const performShortcutAction = (action: ShortcutAction, event: KeyboardEvent): boolean => {
|
||||
switch (action.kind) {
|
||||
case "none":
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const handleDispatchOnlyAction = (action: string): boolean | null => {
|
||||
switch (action) {
|
||||
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();
|
||||
case "dispatch":
|
||||
return keyboardActionDispatcher.dispatch(action.action);
|
||||
case "navigate-workspace":
|
||||
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
||||
return true;
|
||||
case "settings.toggle":
|
||||
return handleSettingsToggle();
|
||||
case "sidebar.toggle.both":
|
||||
if (toggleBothSidebars) {
|
||||
toggleBothSidebars();
|
||||
}
|
||||
case "router-replace":
|
||||
router.replace(action.route as Parameters<typeof router.replace>[0]);
|
||||
return true;
|
||||
case "view.toggle.focus":
|
||||
if (toggleFocusMode) {
|
||||
toggleFocusMode();
|
||||
}
|
||||
case "router-back":
|
||||
router.back();
|
||||
return true;
|
||||
case "theme.cycle":
|
||||
if (cycleTheme) {
|
||||
cycleTheme();
|
||||
}
|
||||
case "router-push":
|
||||
router.push(action.route as Parameters<typeof router.push>[0]);
|
||||
return true;
|
||||
case "command-center.toggle":
|
||||
return handleCommandCenterToggle(input.event);
|
||||
case "shortcuts.dialog.toggle": {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
|
||||
case "open-project-picker":
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
case "callback":
|
||||
callbacksByName[action.name]?.();
|
||||
return true;
|
||||
case "command-center-toggle": {
|
||||
if (action.nextOpen) {
|
||||
captureCommandCenterFocusRestore(event);
|
||||
}
|
||||
useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen);
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
case "shortcuts-dialog-toggle":
|
||||
useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -384,11 +188,20 @@ export function useKeyboardShortcuts({
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = handleAction({
|
||||
action: result.match.action,
|
||||
payload: result.match.payload,
|
||||
event,
|
||||
});
|
||||
const shortcutAction = routeKeyboardShortcut(
|
||||
{ action: result.match.action, payload: result.match.payload },
|
||||
{
|
||||
pathname,
|
||||
isMobile,
|
||||
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||
navigationActiveWorkspace: getNavigationActiveWorkspaceSelection(),
|
||||
lastNavigationWorkspaceRoute: getLastNavigationWorkspaceRouteSelection(),
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const handled = performShortcutAction(shortcutAction, event);
|
||||
if (!handled) {
|
||||
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 { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
|
||||
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||
|
||||
interface NewWorkspaceScreenProps {
|
||||
serverId: string;
|
||||
@@ -194,36 +195,6 @@ function pickerItemTriggerLabel(item: PickerItem): string {
|
||||
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(
|
||||
branchDetails: ReadonlyArray<{ name: string; committerDate: number }>,
|
||||
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;"
|
||||
const constLine = hunk.lines[1];
|
||||
expect(constLine.tokens).toBeDefined();
|
||||
expect(constLine.tokens!.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have a "keyword" token for "const"
|
||||
const constToken = constLine.tokens!.find((t) => t.text === "const");
|
||||
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");
|
||||
expect(constLine.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "const", style: "keyword" }),
|
||||
);
|
||||
expect(constLine.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "1", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights both added and removed lines correctly", () => {
|
||||
@@ -306,15 +300,15 @@ describe("highlightDiffFile", () => {
|
||||
|
||||
// Removed line: "const bar = 2;"
|
||||
const removedLine = hunk.lines.find((l) => l.type === "remove" && l.content.includes("bar"));
|
||||
expect(removedLine?.tokens).toBeDefined();
|
||||
const removedNumber = removedLine!.tokens!.find((t) => t.text === "2");
|
||||
expect(removedNumber?.style).toBe("number");
|
||||
expect(removedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
|
||||
// Added line: "const bar = 3;"
|
||||
const addedLine = hunk.lines.find((l) => l.type === "add" && l.content.includes("bar"));
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
const addedNumber = addedLine!.tokens!.find((t) => t.text === "3");
|
||||
expect(addedNumber?.style).toBe("number");
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "3", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not modify unsupported file types", () => {
|
||||
@@ -351,15 +345,16 @@ describe("highlightDiffFile", () => {
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const fnLine = hunk.lines[1];
|
||||
expect(fnLine.tokens).toBeDefined();
|
||||
expect(fnLine.tokens!.some((t) => t.text === "fn" && t.style === "keyword")).toBe(true);
|
||||
expect(hunk.lines[1].tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "fn", style: "keyword" }),
|
||||
);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to C code", () => {
|
||||
@@ -367,15 +362,14 @@ describe("highlightDiffFile", () => {
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const mainLine = hunk.lines[1];
|
||||
expect(mainLine.tokens).toBeDefined();
|
||||
expect(mainLine.tokens!.length).toBeGreaterThan(0);
|
||||
expect(hunk.lines[1].tokens?.length).toBeGreaterThan(0);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Java code", () => {
|
||||
@@ -383,15 +377,16 @@ describe("highlightDiffFile", () => {
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const classLine = hunk.lines[1];
|
||||
expect(classLine.tokens).toBeDefined();
|
||||
expect(classLine.tokens!.some((t) => t.text === "public" && t.style === "keyword")).toBe(true);
|
||||
expect(hunk.lines[1].tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "public", style: "keyword" }),
|
||||
);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Objective-C file extensions", () => {
|
||||
@@ -402,8 +397,9 @@ describe("highlightDiffFile", () => {
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Go code", () => {
|
||||
@@ -413,8 +409,9 @@ describe("highlightDiffFile", () => {
|
||||
(line) => line.type === "add" && line.content.includes("version"),
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to PHP code", () => {
|
||||
@@ -424,8 +421,9 @@ describe("highlightDiffFile", () => {
|
||||
(line) => line.type === "add" && line.content.includes("$version"),
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(
|
||||
expect.objectContaining({ text: "2", style: "number" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to YAML code", () => {
|
||||
@@ -435,8 +433,7 @@ describe("highlightDiffFile", () => {
|
||||
(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", () => {
|
||||
@@ -446,8 +443,7 @@ describe("highlightDiffFile", () => {
|
||||
(line) => line.type === "add" && line.content.includes("<count>"),
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.style === "tag")).toBe(true);
|
||||
expect(addedLine?.tokens).toContainEqual(expect.objectContaining({ style: "tag" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -456,7 +452,9 @@ describe("parseAndHighlightDiff", () => {
|
||||
const files = parseAndHighlightDiff(SIMPLE_DIFF);
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -3,7 +3,13 @@ import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} 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 PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
@@ -335,3 +341,38 @@ describe("formatAge", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
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.type).toBe("long-running");
|
||||
expect(devRow.portText).toBe("3000");
|
||||
expect(devRow.id).toBeTruthy();
|
||||
expect(devRow.id).toMatch(/^script-draft-\d+$/);
|
||||
expect(buildRow.name).toBe("build");
|
||||
expect(buildRow.commandText).toBe("npm\nrun\nbuild");
|
||||
expect(buildRow.commandOriginalKind).toBe("array");
|
||||
|
||||
Reference in New Issue
Block a user