Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions e2e/full-chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,25 @@ test("a reply in the thread tab adds to the count under its parent", async ({
).toBeVisible();
await expect(log.getByRole("button", { name: /4 replies/ })).toBeVisible();
});

for (const [item, list, draft, typed] of [
["Mention an agent", "Mentions", "", "@"],
["Mention an agent", "Mentions", "Check this", "Check this @"],
["Use a command", "Commands", "", "/"],
// A command starts a line, so it goes on a new one.
["Use a command", "Commands", "Check this", "Check this\n/"],
] as const) {
test(`"${item}" in the Add menu opens the ${list.toLowerCase()} at once, after "${draft}"`, async ({
page,
}) => {
await open(page, "settle");
const main = page.getByRole("main");
const message = main.getByRole("textbox", { name: "Message" });
await message.fill(draft);
await main.getByRole("button", { name: "Add", exact: true }).click();
await page.getByRole("menuitem", { name: item }).click();
await expect(message).toBeFocused();
await expect(message).toHaveValue(typed);
await expect(main.getByRole("listbox", { name: list })).toBeVisible();
});
}
14 changes: 8 additions & 6 deletions e2e/prompt-input-toolbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { expect, test, type Page } from "@playwright/test";

const events = (page: Page) => page.getByRole("status", { name: "Events" });
const message = (page: Page) => page.getByRole("textbox", { name: "Message" });
const model = (page: Page) => page.getByRole("combobox", { name: "Model" });
const mode = (page: Page) =>
page.getByRole("combobox", { name: "Mode", exact: true });
// A closed picker is named for its field and its choice: "Model, Deep".
const model = (page: Page) => page.getByRole("combobox", { name: /^Model, / });
const mode = (page: Page) => page.getByRole("combobox", { name: /^Mode, / });
const add = (page: Page) =>
page.getByRole("button", { name: "Add", exact: true });

Expand All @@ -22,6 +22,7 @@ test.beforeEach(async ({ page }) => {
test("the model picker opens, moves and chooses by keyboard, keeping focus on its trigger", async ({
page,
}) => {
await expect(model(page)).toHaveAccessibleName("Model, Deep");
await model(page).focus();
await page.keyboard.press("Enter");
const list = page.getByRole("listbox", { name: "Model" });
Expand All @@ -41,7 +42,7 @@ test("the model picker opens, moves and chooses by keyboard, keeping focus on it
await page.keyboard.press("Enter");
await expect(list).toBeHidden();
await expect(events(page)).toHaveText("model:lite");
await expect(model(page)).toHaveText("Lite");
await expect(model(page)).toHaveAccessibleName("Model, Lite");
await expect(model(page)).toBeFocused();
});

Expand All @@ -59,14 +60,15 @@ test("Escape closes a picker without choosing and leaves focus on its trigger",
page.getByRole("listbox", { name: "Mode", exact: true }),
).toBeHidden();
await expect(mode(page)).toBeFocused();
await expect(mode(page)).toHaveText("Ask");
await expect(mode(page)).toHaveAccessibleName("Mode, Ask");
await expect(events(page)).toHaveText("");

// Space opens and chooses too.
await page.keyboard.press(" ");
await page.keyboard.press("ArrowDown");
await page.keyboard.press(" ");
await expect(events(page)).toHaveText("mode:plan");
await expect(mode(page)).toHaveAccessibleName("Mode, Plan");
await expect(
page.getByRole("listbox", { name: "Mode", exact: true }),
).toBeHidden();
Expand All @@ -79,7 +81,7 @@ test("a picker chooses by pointer and closes on a press outside", async ({
await model(page).click();
await page.getByRole("option", { name: "Swift 2" }).click();
await expect(events(page)).toHaveText("model:swift");
await expect(model(page)).toHaveText("Swift");
await expect(model(page)).toHaveAccessibleName("Model, Swift");

await model(page).click();
await expect(page.getByRole("listbox", { name: "Model" })).toBeVisible();
Expand Down
14 changes: 14 additions & 0 deletions src/lib/prompt-tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,18 @@ describe("splitPromptSegments", () => {
expect(splitPromptSegments("hello", TOKENS)).toEqual([{ text: "hello" }]);
expect(splitPromptSegments("", TOKENS)).toEqual([]);
});

test("an empty token is skipped rather than matched forever", () => {
const empty: PromptToken[] = [
{ id: "empty-mention", kind: "mention", token: "" },
{ id: "empty-command", kind: "command", token: "" },
];
expect(
splitPromptSegments(" @Ada and\n\n", [...empty, ...TOKENS]),
).toEqual([
{ text: " " },
{ text: "@Ada", token: ADA },
{ text: " and\n\n" },
]);
});
});
8 changes: 4 additions & 4 deletions src/lib/prompt-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,15 @@ export function insertPromptToken(
* Splits prompt text into plain runs and known tokens. A token counts only as
* a whole word where its kind may start (see `findActivePromptToken`): "@Ada"
* in "@Ada, can you", not in "@Adam" or "x@Ada". Longer tokens win, so
* "@Ada Lovelace" is found before "@Ada".
* "@Ada Lovelace" is found before "@Ada". An empty token matches nothing.
*/
export function splitPromptSegments(
value: string,
tokens: readonly PromptToken[],
): readonly PromptSegment[] {
const longestFirst = [...tokens].sort(
(a, b) => b.token.length - a.token.length,
);
const longestFirst = tokens
.filter((token) => token.token !== "")
.sort((a, b) => b.token.length - a.token.length);
const segments: PromptSegment[] = [];
let runStart = 0;
let index = 0;
Expand Down
102 changes: 98 additions & 4 deletions src/ui/prompt-input-toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,21 @@ function mount(node: ReactNode) {
};
}

function press(target: Element, key: string): KeyboardEvent {
/** `keyCode` is set on the event itself: `KeyboardEventInit` drops it. */
function press(
target: Element,
key: string,
{ keyCode, ...init }: KeyboardEventInit & { keyCode?: number } = {},
): KeyboardEvent {
const event = new KeyboardEvent("keydown", {
...init,
key,
bubbles: true,
cancelable: true,
});
if (keyCode !== undefined) {
Object.defineProperty(event, "keyCode", { value: keyCode });
}
act(() => {
target.dispatchEvent(event);
});
Expand Down Expand Up @@ -115,7 +124,8 @@ describe("PromptInputModelSelect", () => {
test("shows the first model's short name by default and lists models by group", () => {
const view = mount(<PromptInputModelSelect models={MODELS} />);
const trigger = view.query("[role='combobox']");
expect(trigger.getAttribute("aria-label")).toBe("Model");
// Named for the field and the choice, as a closed picker is heard.
expect(trigger.getAttribute("aria-label")).toBe("Model, Swift");
expect(trigger.textContent).toBe("Swift");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.hasAttribute("aria-controls")).toBe(false);
Expand Down Expand Up @@ -255,7 +265,9 @@ describe("PromptInputModelSelect", () => {
onValueChange={() => undefined}
/>,
);
expect(view.query("[role='combobox']").textContent).toBe("Model");
const trigger = view.query("[role='combobox']");
expect(trigger.textContent).toBe("Model");
expect(trigger.getAttribute("aria-label")).toBe("Model");
view.unmount();
});

Expand Down Expand Up @@ -369,7 +381,7 @@ describe("PromptInputModeSelect", () => {
const events: string[] = [];
const view = mount(<Host events={events} />);
const trigger = view.query<HTMLButtonElement>("[role='combobox']");
expect(trigger.getAttribute("aria-label")).toBe("Mode");
expect(trigger.getAttribute("aria-label")).toBe("Mode, Agent");
expect(trigger.textContent).toBe("Agent");

act(() => trigger.focus());
Expand All @@ -381,6 +393,7 @@ describe("PromptInputModeSelect", () => {
press(trigger, " ");
expect(events).toEqual(["ask"]);
expect(trigger.textContent).toBe("Ask");
expect(trigger.getAttribute("aria-label")).toBe("Mode, Ask");
view.unmount();
});

Expand Down Expand Up @@ -590,6 +603,66 @@ describe("PromptInputAddMenu", () => {
});
});

describe("a popup whose options run out", () => {
/** Each popup with or without its options, open state owned by the host
* when `controlled`. */
const POPUP_PARTS = {
add: {
popup: "[role='menu']",
render: (empty: boolean, state: PopupState) => (
<PromptInputAddMenu items={empty ? [] : menuItems([])} {...state} />
),
},
model: {
popup: "[role='listbox']",
render: (empty: boolean, state: PopupState) => (
<PromptInputModelSelect models={empty ? [] : MODELS} {...state} />
),
},
mode: {
popup: "[role='listbox']",
render: (empty: boolean, state: PopupState) => (
<PromptInputModeSelect modes={empty ? [] : MODES} {...state} />
),
},
} as const;
type PopupState = {
readonly open?: boolean;
readonly defaultOpen?: boolean;
readonly onOpenChange: (open: boolean) => void;
};

for (const which of ["add", "model", "mode"] as const) {
for (const controlled of [false, true]) {
test(`the ${controlled ? "host-owned" : "own"} ${which} popup closes once and stays closed when they return`, () => {
const { popup, render } = POPUP_PARTS[which];
const changes: boolean[] = [];
function Host({ empty }: { readonly empty: boolean }) {
const [open, setOpen] = useState(true);
const onOpenChange = (next: boolean) => {
changes.push(next);
setOpen(next);
};
return render(
empty,
controlled
? { open, onOpenChange }
: { defaultOpen: true, onOpenChange },
);
}
const view = mount(<Host empty={false} />);
expect(shown(view, popup)).toBe(true);
view.rerender(<Host empty />);
expect(changes).toEqual([false]);
view.rerender(<Host empty={false} />);
expect(shown(view, popup)).toBe(false);
expect(changes).toEqual([false]);
view.unmount();
});
}
}
});

describe("PromptInputButton", () => {
test("never submits the form it sits in", () => {
const submits: string[] = [];
Expand Down Expand Up @@ -862,6 +935,27 @@ describe("toolbar parts inside PromptInput", () => {
});
}

// Safari sends the key that ends a composition with `isComposing` already
// false and `keyCode` 229.
for (const [how, init] of [
["isComposing", { isComposing: true }],
["keyCode 229", { isComposing: false, keyCode: 229 }],
] as const) {
for (const which of ["add", "model"] as const) {
test(`an Escape that ends an IME composition (${how}) leaves the host-opened ${which} popup open`, () => {
const events: string[] = [];
const view = mount(<Prompt open={which} events={events} />);
const textarea = view.query<HTMLTextAreaElement>("textarea");
act(() => textarea.focus());
const event = press(textarea, "Escape", init);
expect(event.defaultPrevented).toBe(false);
expect(events).toEqual([]);
expect(shown(view, POPUPS[which].popup)).toBe(true);
view.unmount();
});
}
}

test("with the suggestion list open too, the first Escape closes only the list", () => {
const view = mount(<Prompt open="add" />);
const textarea = view.query<HTMLTextAreaElement>("textarea");
Expand Down
Loading
Loading