mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
* feat(app): show loading and errors in the composer command menu Rows are single lines with a kind icon, the matched part of the name, the argument hint, the description and a source tag. The detail card is gone. The menu used to stay hidden while commands loaded and then show "Error: ..." over a list whose options Enter could still pick, so a failed load plus Enter archived the agent through a hidden /exit. Loading and error are now status rows with no options; the error row names the reason and offers Retry. Loading appears after 300ms so a warm cache paints the results first, as before. An empty query used to select /exit. Provider commands now come first, so the bottom row the input selects is a provider command. useAutocomplete derives the selection during render. When results replace the loading row, the selection is valid in the same render, so the popover stays mounted at its anchor instead of remounting. Claude Code's `__`-prefixed commands (/__remote-workflow) only run in its own server-launched sessions, so the Claude provider no longer lists them. * fix(app): never default the command menu to a Paseo action An arrow-key choice was tied to the query it was made for, so returning to that query brought it back: arrow to /exit on `/`, type `com`, delete it, and Enter archived the agent. A choice now clears whenever the query changes. An empty query still selected /exit when the agent had no provider or plugin commands, because Paseo's actions were the only entries. With nothing else to list, an empty query now lists nothing and waits for a search; typing `/ex` still finds /exit. * fix(app): sort empty-query commands without toSorted Hermes has no Array.prototype.toSorted, so typing `/` threw "undefined is not a function" on iOS and Android and the app showed its error screen. Browsers and Node have it, which is why web and unit tests passed. * test(app): cover the stale command choice in Playwright and wait out the loading row on devices The renderHook test mounted React under JSDOM, which docs/testing.md forbids. The Playwright test drives the same sequence in a real browser and fails without the reset in useAutocomplete: Enter chose the stale /exit. The composer-keyboard harnesses treated a visible popover as a ready list. The popover now shows a loading row first, and a cold Claude command list takes several seconds, so both harnesses wait for the rows. * fix(app): size the command menu loading spinner to the row icons Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
910 lines
30 KiB
TypeScript
910 lines
30 KiB
TypeScript
import { expect, test, type Page } from "../support/fixtures";
|
|
import {
|
|
attachFileFromMenu,
|
|
composerLocator,
|
|
expectComposerVisible,
|
|
} from "../support/helpers/composer";
|
|
import {
|
|
openAgentRoute,
|
|
seedMockAgentWorkspace,
|
|
type MockAgentWorkspace,
|
|
} from "../support/helpers/mock-agent";
|
|
import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/archive-tab";
|
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
|
import {
|
|
closeMobileAgentSidebar,
|
|
expectMobileAgentSidebarHidden,
|
|
expectMobileAgentSidebarVisible,
|
|
openMobileAgentSidebar,
|
|
} from "../support/helpers/sidebar";
|
|
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
|
|
|
|
const TEST_COMMANDS = [
|
|
{
|
|
name: "tdd",
|
|
description: "Write a red test, verify it fails for the right reason, implement to green",
|
|
argumentHint: "",
|
|
},
|
|
{
|
|
name: "help",
|
|
description: "Show help for the current agent session and available slash commands",
|
|
argumentHint: "",
|
|
},
|
|
{
|
|
name: "hello",
|
|
description: "Insert a friendly greeting prompt into the current composer",
|
|
argumentHint: "",
|
|
},
|
|
{
|
|
name: "heapdump",
|
|
description: "Dump the JavaScript heap for local desktop debugging",
|
|
argumentHint: "",
|
|
},
|
|
{
|
|
name: "health",
|
|
description: "Show runtime health checks and connection diagnostics",
|
|
argumentHint: "",
|
|
},
|
|
{
|
|
name: "history",
|
|
description: "Summarize recent session history",
|
|
argumentHint: "",
|
|
},
|
|
{
|
|
name: "handoff",
|
|
description: "Prepare a complete handoff note for another agent",
|
|
argumentHint: "[agent]",
|
|
},
|
|
{
|
|
name: "hover",
|
|
description: "Audit hover behavior in desktop web surfaces",
|
|
argumentHint: "",
|
|
},
|
|
{
|
|
name: "harness",
|
|
description: "Inspect the local test harness configuration",
|
|
argumentHint: "",
|
|
},
|
|
{
|
|
name: "hydrate",
|
|
description: "Refresh persisted state used by the current workspace",
|
|
argumentHint: "",
|
|
},
|
|
{
|
|
name: "highlight",
|
|
description: "Highlight important changes in the active diff",
|
|
argumentHint: "",
|
|
},
|
|
{
|
|
name: "home",
|
|
description: "Navigate back to the workspace home surface",
|
|
argumentHint: "",
|
|
},
|
|
{
|
|
name: "host",
|
|
description: "Inspect host connection metadata",
|
|
argumentHint: "",
|
|
},
|
|
] as const;
|
|
|
|
interface PopoverFrame {
|
|
exists: boolean;
|
|
top: number;
|
|
bottom: number;
|
|
height: number;
|
|
opacity: number;
|
|
display: string;
|
|
visibility: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
interface PopoverFrameRecorderWindow extends Window {
|
|
__composerAutocompleteFrames?: PopoverFrame[];
|
|
__stopComposerAutocompleteFrameRecorder?: () => void;
|
|
}
|
|
|
|
interface ListCommandsReply {
|
|
commands: readonly { name: string; description: string; argumentHint: string }[];
|
|
error: string | null;
|
|
}
|
|
|
|
const TEST_COMMANDS_REPLY: ListCommandsReply = { commands: TEST_COMMANDS, error: null };
|
|
|
|
/**
|
|
* Rewrites every `list_commands_response` with `reply()`. A reply that returns a promise holds
|
|
* the response until it settles, standing in for a slow host.
|
|
*/
|
|
async function installListCommandsStub(
|
|
page: Page,
|
|
reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => TEST_COMMANDS_REPLY,
|
|
): Promise<void> {
|
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
|
const server = ws.connectToServer();
|
|
|
|
ws.onMessage((message) => {
|
|
server.send(message);
|
|
});
|
|
|
|
server.onMessage((message) => {
|
|
if (typeof message !== "string") {
|
|
ws.send(message);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(message) as {
|
|
type?: string;
|
|
message?: {
|
|
type?: string;
|
|
payload?: {
|
|
commands?: unknown;
|
|
error?: string | null;
|
|
};
|
|
};
|
|
};
|
|
const payload = parsed.message?.payload;
|
|
if (
|
|
parsed.type === "session" &&
|
|
parsed.message?.type === "list_commands_response" &&
|
|
payload
|
|
) {
|
|
const send = (result: ListCommandsReply) => {
|
|
payload.commands = result.commands;
|
|
payload.error = result.error;
|
|
ws.send(JSON.stringify(parsed));
|
|
};
|
|
const result = reply();
|
|
if (result instanceof Promise) void result.then(send);
|
|
else send(result);
|
|
return;
|
|
}
|
|
} catch {
|
|
// Forward non-JSON frames unchanged.
|
|
}
|
|
|
|
ws.send(message);
|
|
});
|
|
});
|
|
}
|
|
|
|
function createHeldReply(): {
|
|
reply: () => Promise<ListCommandsReply>;
|
|
release: () => void;
|
|
} {
|
|
let release = () => {};
|
|
const held = new Promise<ListCommandsReply>((resolve) => {
|
|
release = () => resolve(TEST_COMMANDS_REPLY);
|
|
});
|
|
return { reply: () => held, release };
|
|
}
|
|
|
|
async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
|
await page.getByTestId("sidebar-global-new-workspace").first().click();
|
|
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
|
}
|
|
|
|
async function openSettingsThenBackToWorkspace(page: Page): Promise<void> {
|
|
await page.getByTestId("sidebar-settings").filter({ visible: true }).first().click();
|
|
await expect(page).toHaveURL(/\/settings\/general$/, { timeout: 30_000 });
|
|
await page.getByTestId("settings-back-to-workspace").click();
|
|
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
|
|
}
|
|
|
|
async function expectSingleCurrentWorkspaceDeckEntry(
|
|
page: Page,
|
|
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
|
|
): Promise<void> {
|
|
const summary = await page
|
|
.locator('[data-testid^="workspace-deck-entry-"]')
|
|
.evaluateAll((elements, target) => {
|
|
const currentTestId = `workspace-deck-entry-${target.serverId}:${target.workspaceId}`;
|
|
const entries = elements.map((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
testId: element.getAttribute("data-testid"),
|
|
hasLayout: rect.width > 0 && rect.height > 0,
|
|
};
|
|
});
|
|
const currentEntries = entries.filter((entry) => entry.testId === currentTestId);
|
|
return {
|
|
totalDeckEntryCount: entries.length,
|
|
currentWorkspaceEntryCount: currentEntries.length,
|
|
visibleCurrentWorkspaceEntryCount: currentEntries.filter((entry) => entry.hasLayout).length,
|
|
hiddenCurrentWorkspaceEntryCount: currentEntries.filter((entry) => !entry.hasLayout).length,
|
|
};
|
|
}, input);
|
|
|
|
expect(summary).toEqual({
|
|
totalDeckEntryCount: input.expectedDeckEntryCount,
|
|
currentWorkspaceEntryCount: 1,
|
|
visibleCurrentWorkspaceEntryCount: 1,
|
|
hiddenCurrentWorkspaceEntryCount: 0,
|
|
});
|
|
}
|
|
|
|
async function openReadyMockAgent(
|
|
page: Page,
|
|
options?: { expectWorkspaceTab?: boolean },
|
|
): Promise<{
|
|
cleanup: () => Promise<void>;
|
|
}> {
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "autocomplete-popover-",
|
|
title: "Autocomplete popover regression",
|
|
});
|
|
|
|
try {
|
|
await openAgentRoute(page, session);
|
|
if (options?.expectWorkspaceTab !== false) {
|
|
await expectWorkspaceTabVisible(page, session.agentId);
|
|
}
|
|
await expectComposerVisible(page);
|
|
return { cleanup: session.cleanup };
|
|
} catch (error) {
|
|
await session.cleanup();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function visiblePopoverBox(
|
|
page: Page,
|
|
): Promise<{ top: number; bottom: number; height: number }> {
|
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
|
await expect(popover).toBeVisible({ timeout: 30_000 });
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
popover.evaluate((element) => {
|
|
const style = window.getComputedStyle(element);
|
|
return Number(style.opacity);
|
|
}),
|
|
{ timeout: 30_000 },
|
|
)
|
|
.toBeGreaterThan(0.95);
|
|
const box = await popover.boundingBox();
|
|
if (!box) {
|
|
throw new Error("Autocomplete popover did not produce a bounding box.");
|
|
}
|
|
return {
|
|
top: box.y,
|
|
bottom: box.y + box.height,
|
|
height: box.height,
|
|
};
|
|
}
|
|
|
|
async function startPopoverFrameRecorder(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const win = window as PopoverFrameRecorderWindow;
|
|
win.__composerAutocompleteFrames = [];
|
|
let active = true;
|
|
|
|
const record = () => {
|
|
if (!active) return;
|
|
|
|
const element = document.querySelector('[data-testid="composer-autocomplete-popover"]');
|
|
if (element instanceof HTMLElement) {
|
|
const rect = element.getBoundingClientRect();
|
|
const style = window.getComputedStyle(element);
|
|
win.__composerAutocompleteFrames?.push({
|
|
exists: true,
|
|
top: rect.top,
|
|
bottom: rect.bottom,
|
|
height: rect.height,
|
|
opacity: Number(style.opacity),
|
|
display: style.display,
|
|
visibility: style.visibility,
|
|
timestamp: performance.now(),
|
|
});
|
|
} else {
|
|
win.__composerAutocompleteFrames?.push({
|
|
exists: false,
|
|
top: 0,
|
|
bottom: 0,
|
|
height: 0,
|
|
opacity: 0,
|
|
display: "none",
|
|
visibility: "hidden",
|
|
timestamp: performance.now(),
|
|
});
|
|
}
|
|
|
|
window.requestAnimationFrame(record);
|
|
};
|
|
|
|
win.__stopComposerAutocompleteFrameRecorder = () => {
|
|
active = false;
|
|
};
|
|
window.requestAnimationFrame(record);
|
|
});
|
|
}
|
|
|
|
async function stopPopoverFrameRecorder(page: Page): Promise<PopoverFrame[]> {
|
|
return page.evaluate(() => {
|
|
const win = window as PopoverFrameRecorderWindow;
|
|
win.__stopComposerAutocompleteFrameRecorder?.();
|
|
return win.__composerAutocompleteFrames ?? [];
|
|
});
|
|
}
|
|
|
|
function visiblePopoverFrames(frames: PopoverFrame[]): PopoverFrame[] {
|
|
return frames.filter(
|
|
(frame) =>
|
|
frame.exists &&
|
|
frame.height > 0 &&
|
|
frame.opacity > 0.95 &&
|
|
frame.display !== "none" &&
|
|
frame.visibility !== "hidden",
|
|
);
|
|
}
|
|
|
|
function formatFrame(frame: PopoverFrame | undefined): string {
|
|
if (!frame) return "none";
|
|
return JSON.stringify({
|
|
top: Math.round(frame.top),
|
|
bottom: Math.round(frame.bottom),
|
|
height: Math.round(frame.height),
|
|
opacity: frame.opacity,
|
|
});
|
|
}
|
|
|
|
function expectPopoverFramesStable(frames: PopoverFrame[]): void {
|
|
const visibleFrames = visiblePopoverFrames(frames);
|
|
expect(visibleFrames.length).toBeGreaterThan(0);
|
|
|
|
const finalFrame = visibleFrames[visibleFrames.length - 1];
|
|
const jumpingFrame = visibleFrames.find(
|
|
(frame) =>
|
|
Math.abs(frame.top - finalFrame.top) > 4 ||
|
|
Math.abs(frame.bottom - finalFrame.bottom) > 4 ||
|
|
Math.abs(frame.height - finalFrame.height) > 4,
|
|
);
|
|
|
|
expect(
|
|
jumpingFrame,
|
|
`expected first visible popover paint to be stable; first=${formatFrame(
|
|
visibleFrames[0],
|
|
)} jumping=${formatFrame(jumpingFrame)} final=${formatFrame(finalFrame)}`,
|
|
).toBeUndefined();
|
|
}
|
|
|
|
function recordCommandRequestsByAgentId(page: Page): string[] {
|
|
const requests: string[] = [];
|
|
const record = ({ payload }: { payload: string | Buffer }) => {
|
|
if (typeof payload !== "string" || !payload.includes("list_commands_request")) return;
|
|
if (!payload.includes("draftConfig")) requests.push(payload);
|
|
};
|
|
page.on("websocket", (socket) => socket.on("framesent", record));
|
|
return requests;
|
|
}
|
|
|
|
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
|
|
const firstVisibleIndex = frames.findIndex(
|
|
(frame) =>
|
|
frame.exists &&
|
|
frame.height > 0 &&
|
|
frame.opacity > 0.95 &&
|
|
frame.display !== "none" &&
|
|
frame.visibility !== "hidden",
|
|
);
|
|
expect(firstVisibleIndex).toBeGreaterThanOrEqual(0);
|
|
|
|
const hiddenFrame = frames
|
|
.slice(firstVisibleIndex)
|
|
.find((frame) => !frame.exists || frame.opacity < 0.95);
|
|
expect(
|
|
hiddenFrame,
|
|
`expected mounted popover to stay visible while filtering; hidden=${formatFrame(hiddenFrame)}`,
|
|
).toBeUndefined();
|
|
}
|
|
|
|
test.describe("Composer autocomplete", () => {
|
|
test("inserts suggested commands from assistant messages without sending", async ({ page }) => {
|
|
const requests = recordCommandRequestsByAgentId(page);
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "suggested-command-",
|
|
title: "Suggested commands",
|
|
initialPrompt: "Suggest `/mock` without running it",
|
|
featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." },
|
|
});
|
|
try {
|
|
await openAgentRoute(page, session);
|
|
const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" });
|
|
await expect(message).toBeVisible();
|
|
await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0);
|
|
await expect.poll(() => requests.length).toBe(1);
|
|
await attachFileFromMenu(page, {
|
|
name: "note.txt",
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("attachment"),
|
|
});
|
|
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
|
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
|
await expect(composerLocator(page)).toHaveValue("/mock ");
|
|
await expect(
|
|
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
|
).toHaveCount(0);
|
|
await expect(composerLocator(page)).toBeFocused();
|
|
await message.getByRole("link", { name: "Run mock" }).click();
|
|
await expect(composerLocator(page)).toHaveValue("/mock /mock ");
|
|
await expect(
|
|
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
|
).toHaveCount(0);
|
|
await expect(composerLocator(page)).toBeFocused();
|
|
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
|
await composerLocator(page).fill("draft");
|
|
await composerLocator(page).evaluate((element: HTMLTextAreaElement) =>
|
|
element.setSelectionRange(0, 0),
|
|
);
|
|
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
|
await composerLocator(page).pressSequentially("argument");
|
|
await expect(composerLocator(page)).toHaveValue("draft /mock argument");
|
|
await composerLocator(page).fill("/");
|
|
await expect(
|
|
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
|
).toContainText("/mock");
|
|
expect(requests).toHaveLength(1);
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
|
|
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
|
page,
|
|
}) => {
|
|
const commandRequestsByAgentId = recordCommandRequestsByAgentId(page);
|
|
|
|
await page.goto("/new");
|
|
await expectNewWorkspaceProjectSelected(page, "Choose project");
|
|
await composerLocator(page).fill("/");
|
|
|
|
const popover = page
|
|
.getByTestId("composer-autocomplete-popover")
|
|
.filter({ visible: true })
|
|
.first();
|
|
await expect(popover).toContainText("Choose a project to see commands", { timeout: 30_000 });
|
|
await expect(popover).not.toContainText("/clear");
|
|
await expect(popover).not.toContainText("/exit");
|
|
expect(commandRequestsByAgentId).toEqual([]);
|
|
});
|
|
|
|
test("stays visible after returning from app-wide routes", async ({ page }) => {
|
|
await installListCommandsStub(page);
|
|
const serverId = getServerId();
|
|
const sessions: MockAgentWorkspace[] = [];
|
|
|
|
try {
|
|
sessions.push(
|
|
await seedMockAgentWorkspace({
|
|
repoPrefix: "autocomplete-new-route-a-",
|
|
title: "Autocomplete new route A",
|
|
}),
|
|
);
|
|
sessions.push(
|
|
await seedMockAgentWorkspace({
|
|
repoPrefix: "autocomplete-new-route-b-",
|
|
title: "Autocomplete new route B",
|
|
}),
|
|
);
|
|
sessions.push(
|
|
await seedMockAgentWorkspace({
|
|
repoPrefix: "autocomplete-new-route-c-",
|
|
title: "Autocomplete new route C",
|
|
}),
|
|
);
|
|
|
|
const [first, second, third] = sessions;
|
|
|
|
await openAgentRoute(page, first);
|
|
await expectComposerVisible(page, { timeout: 30_000 });
|
|
|
|
await openAppWideNewWorkspace(page);
|
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
|
await expectComposerVisible(page, { timeout: 30_000 });
|
|
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
|
expectedDeckEntryCount: 2,
|
|
serverId,
|
|
workspaceId: second.workspaceId,
|
|
});
|
|
|
|
await openSettingsThenBackToWorkspace(page);
|
|
await expectComposerVisible(page, { timeout: 30_000 });
|
|
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
|
expectedDeckEntryCount: 2,
|
|
serverId,
|
|
workspaceId: second.workspaceId,
|
|
});
|
|
|
|
await openSessions(page);
|
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
|
await expectComposerVisible(page, { timeout: 30_000 });
|
|
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
|
expectedDeckEntryCount: sessions.length,
|
|
serverId,
|
|
workspaceId: third.workspaceId,
|
|
});
|
|
|
|
await openAppWideNewWorkspace(page);
|
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
|
|
await expectComposerVisible(page, { timeout: 30_000 });
|
|
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
|
expectedDeckEntryCount: sessions.length,
|
|
serverId,
|
|
workspaceId: first.workspaceId,
|
|
});
|
|
|
|
await composerLocator(page).fill("/");
|
|
const popover = page
|
|
.getByTestId("composer-autocomplete-popover")
|
|
.filter({ hasText: "/help", visible: true })
|
|
.first();
|
|
await expect(popover).toBeInViewport({ timeout: 30_000 });
|
|
} finally {
|
|
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
|
}
|
|
});
|
|
|
|
test("does not flash at the wrong position on the first slash command paint", async ({
|
|
page,
|
|
}) => {
|
|
await installListCommandsStub(page);
|
|
const agent = await openReadyMockAgent(page);
|
|
|
|
try {
|
|
const input = composerLocator(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
await input.click();
|
|
|
|
await startPopoverFrameRecorder(page);
|
|
await page.keyboard.type("/");
|
|
await expect(page.getByText("/help", { exact: true })).toBeVisible({ timeout: 30_000 });
|
|
await page.waitForTimeout(250);
|
|
const frames = await stopPopoverFrameRecorder(page);
|
|
|
|
expectPopoverFramesStable(frames);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("does not jump when deleting a slash command search", async ({ page }) => {
|
|
await installListCommandsStub(page);
|
|
const agent = await openReadyMockAgent(page);
|
|
|
|
try {
|
|
const input = composerLocator(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
|
|
// `/hea` lists two commands and `/he` enough to reach the height cap, so the list grows.
|
|
await input.fill("/hea");
|
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
|
await expect(popover.getByText("/heapdump", { exact: true }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
const beforeDelete = await visiblePopoverBox(page);
|
|
|
|
await input.press("Backspace");
|
|
await expect(input).toHaveValue("/he");
|
|
await expect(popover.getByText("/hello", { exact: true }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
const afterDelete = await visiblePopoverBox(page);
|
|
|
|
expect(Math.abs(afterDelete.bottom - beforeDelete.bottom)).toBeLessThanOrEqual(4);
|
|
expect(afterDelete.height).toBeGreaterThan(beforeDelete.height);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("shrinks to filtered slash command results without moving the bottom edge", async ({
|
|
page,
|
|
}) => {
|
|
await installListCommandsStub(page);
|
|
const agent = await openReadyMockAgent(page);
|
|
|
|
try {
|
|
const input = composerLocator(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
|
|
await input.fill("/");
|
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
const allCommands = await visiblePopoverBox(page);
|
|
|
|
await input.fill("/tdd");
|
|
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
const oneCommand = await visiblePopoverBox(page);
|
|
|
|
expect(Math.abs(oneCommand.bottom - allCommands.bottom)).toBeLessThanOrEqual(4);
|
|
expect(oneCommand.height).toBeLessThan(allCommands.height - 40);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("stays visible while filtering slash command results", async ({ page }) => {
|
|
await installListCommandsStub(page);
|
|
const agent = await openReadyMockAgent(page);
|
|
|
|
try {
|
|
const input = composerLocator(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
|
|
await input.fill("/");
|
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
await startPopoverFrameRecorder(page);
|
|
await page.keyboard.type("tdd", { delay: 40 });
|
|
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
await page.waitForTimeout(250);
|
|
const frames = await stopPopoverFrameRecorder(page);
|
|
|
|
expectPopoverDoesNotDisappearAfterFirstVisible(frames);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("uses the live cursor when accepting a slash command", async ({ page }) => {
|
|
await installListCommandsStub(page);
|
|
const agent = await openReadyMockAgent(page);
|
|
|
|
try {
|
|
const input = composerLocator(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
await input.fill("/tdd");
|
|
await expect(
|
|
page
|
|
.getByTestId("composer-autocomplete-popover")
|
|
.getByText("/tdd", { exact: true })
|
|
.first(),
|
|
).toBeVisible({ timeout: 30_000 });
|
|
|
|
await input.evaluate((element) => {
|
|
if (!(element instanceof HTMLTextAreaElement)) {
|
|
throw new Error("Composer input is not a textarea");
|
|
}
|
|
element.setSelectionRange(0, 0);
|
|
element.dispatchEvent(new Event("select", { bubbles: true }));
|
|
element.dispatchEvent(
|
|
new KeyboardEvent("keydown", {
|
|
key: "Tab",
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
await expect(input).toHaveValue("/tdd");
|
|
const selectionStart = await input.evaluate(
|
|
(element) => (element as HTMLTextAreaElement).selectionStart,
|
|
);
|
|
expect(selectionStart).toBe(0);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("uses the live input when clicking a slash command", async ({ page }) => {
|
|
await installListCommandsStub(page);
|
|
const agent = await openReadyMockAgent(page);
|
|
|
|
try {
|
|
const input = composerLocator(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
await input.fill("/td");
|
|
const option = page
|
|
.getByTestId("composer-autocomplete-popover")
|
|
.getByText("/tdd", { exact: true })
|
|
.first();
|
|
await expect(option).toBeVisible({ timeout: 30_000 });
|
|
|
|
await option.evaluate((element) => {
|
|
const textarea = document.querySelector('textarea[aria-label="Message agent..."]');
|
|
if (!(textarea instanceof HTMLTextAreaElement)) {
|
|
throw new Error("Composer input is not a textarea");
|
|
}
|
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
|
HTMLTextAreaElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
if (!valueSetter) throw new Error("Textarea value setter is unavailable");
|
|
valueSetter.call(textarea, "/tdd suffix");
|
|
textarea.setSelectionRange(4, 4);
|
|
textarea.dispatchEvent(
|
|
new InputEvent("input", {
|
|
bubbles: true,
|
|
data: "d suffix",
|
|
inputType: "insertText",
|
|
}),
|
|
);
|
|
(element as HTMLElement).click();
|
|
});
|
|
|
|
await expect(input).toHaveValue("/tdd suffix");
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
|
|
await installListCommandsStub(page);
|
|
const agent = await openReadyMockAgent(page);
|
|
|
|
try {
|
|
await expect(page.getByTestId("sidebar-sessions")).toBeVisible({ timeout: 30_000 });
|
|
const input = composerLocator(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
|
|
await input.fill("/");
|
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
const composerBox = await page.getByTestId("message-input-root").boundingBox();
|
|
const popoverBox = await popover.boundingBox();
|
|
expect(composerBox).not.toBeNull();
|
|
expect(popoverBox).not.toBeNull();
|
|
|
|
expect(Math.abs(popoverBox!.x - composerBox!.x)).toBeLessThanOrEqual(4);
|
|
expect(Math.abs(popoverBox!.width - composerBox!.width)).toBeLessThanOrEqual(4);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("forgets an arrow-key choice once the search changes", async ({ page }) => {
|
|
await installListCommandsStub(page);
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "autocomplete-stale-choice-",
|
|
title: "Autocomplete stale choice",
|
|
});
|
|
|
|
try {
|
|
await openAgentRoute(page, session);
|
|
await expectWorkspaceTabVisible(page, session.agentId);
|
|
const input = composerLocator(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
await input.fill("/");
|
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
|
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
// Down wraps from the default bottom row to the top: /clear, then /exit.
|
|
await input.press("ArrowDown");
|
|
await input.press("ArrowDown");
|
|
await input.press("h");
|
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
|
await input.press("Backspace");
|
|
await expect(input).toHaveValue("/");
|
|
await input.press("Enter");
|
|
|
|
await expect(input).toHaveValue("/tdd ");
|
|
await expectWorkspaceTabVisible(page, session.agentId);
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
|
|
test("shows loading inside the popover until a slow host answers", async ({ page }) => {
|
|
const host = createHeldReply();
|
|
await installListCommandsStub(page, host.reply);
|
|
const agent = await openReadyMockAgent(page);
|
|
|
|
try {
|
|
const input = composerLocator(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
await input.fill("/");
|
|
|
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
|
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
const loading = await visiblePopoverBox(page);
|
|
|
|
host.release();
|
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
await expect(popover.getByTestId("composer-autocomplete-loading")).toHaveCount(0);
|
|
const results = await visiblePopoverBox(page);
|
|
|
|
expect(Math.abs(results.bottom - loading.bottom)).toBeLessThanOrEqual(4);
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
|
|
test("shows why commands failed, keeps Enter from choosing a hidden command, and retries", async ({
|
|
page,
|
|
}) => {
|
|
let reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => ({
|
|
commands: [],
|
|
error: "Timed out waiting for the agent session",
|
|
});
|
|
await installListCommandsStub(page, () => reply());
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "autocomplete-failing-host-",
|
|
title: "Autocomplete failing host",
|
|
});
|
|
|
|
try {
|
|
await openAgentRoute(page, session);
|
|
await expectWorkspaceTabVisible(page, session.agentId);
|
|
const input = composerLocator(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
await input.fill("/");
|
|
|
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
|
const errorRow = popover.getByTestId("composer-autocomplete-error");
|
|
await expect(errorRow).toContainText("Timed out waiting for the agent session", {
|
|
timeout: 30_000,
|
|
});
|
|
|
|
await input.press("Enter");
|
|
await page.waitForTimeout(500);
|
|
await expectWorkspaceTabVisible(page, session.agentId);
|
|
|
|
await input.fill("/");
|
|
await expect(errorRow).toBeVisible({ timeout: 30_000 });
|
|
|
|
const host = createHeldReply();
|
|
reply = host.reply;
|
|
await errorRow.getByRole("button", { name: "Retry" }).click();
|
|
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible();
|
|
|
|
host.release();
|
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
|
|
test.describe("compact sidebar layering", () => {
|
|
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
|
|
|
test("keeps the mobile agent sidebar above autocomplete", async ({ page }) => {
|
|
await installListCommandsStub(page);
|
|
const agent = await openReadyMockAgent(page, { expectWorkspaceTab: false });
|
|
|
|
try {
|
|
const input = composerLocator(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
|
|
await input.fill("/");
|
|
const popover = page.getByTestId("composer-autocomplete-popover");
|
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
// The sidebar covers the composer, so the composer gives up its popover while the
|
|
// sidebar is open and brings it back once the sidebar closes.
|
|
await openMobileAgentSidebar(page);
|
|
await expectMobileAgentSidebarVisible(page);
|
|
await expect(popover).toHaveCount(0);
|
|
|
|
await closeMobileAgentSidebar(page);
|
|
await expectMobileAgentSidebarHidden(page);
|
|
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
|
} finally {
|
|
await agent.cleanup();
|
|
}
|
|
});
|
|
});
|
|
});
|