Skip to content

Commit a52195f

Browse files
authored
feat(prompt-input): add the PromptInput toolbar parts
Add PromptInputToolbar with an add menu, icon buttons, model and mode pickers, a float above the box, a context chip and a note. The pickers are select-only comboboxes and the add menu is a menu button; both keep or return focus to their trigger and need no optional peer. A toolbar placed last in PromptInput takes its own row.
1 parent 1f29a63 commit a52195f

7 files changed

Lines changed: 1838 additions & 0 deletions

File tree

‎e2e/fixtures.tsx‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ import {
3434
PanelHandle,
3535
PanelHeader,
3636
PanelPane,
37+
PromptInputToolbar,
38+
PromptInputAddMenu,
39+
PromptInputButton,
40+
PromptInputModelSelect,
41+
PromptInputModeSelect,
42+
PromptInputFloat,
43+
PromptInputContext,
44+
PromptInputNote,
3745
} from "@corbits/react-ui";
3846
import {
3947
CommandPalette,
@@ -570,6 +578,109 @@ function PanelScenario() {
570578
);
571579
}
572580

581+
/** Every toolbar part, with each choice and action recorded. "Toggle
582+
* toolbar" takes the toolbar out, to compare layouts; clearing the reply
583+
* chip removes it and "Reply to Ada" brings it back. */
584+
function PromptToolbarScenario() {
585+
const [value, setValue] = useState("");
586+
const [model, setModel] = useState("deep");
587+
const [mode, setMode] = useState("ask");
588+
const [toolbar, setToolbar] = useState(true);
589+
const [replying, setReplying] = useState(true);
590+
const { log, record } = useEventLog();
591+
return (
592+
<div style={{ width: 520, paddingTop: 280 }}>
593+
<PromptInput
594+
value={value}
595+
onValueChange={setValue}
596+
onSubmit={() => record(`submit:${value}`)}
597+
tokens={PROMPT_TOKENS}
598+
>
599+
{replying ? (
600+
<PromptInputFloat>
601+
<PromptInputContext
602+
label="Replying to"
603+
detail="Ada"
604+
onClear={() => setReplying(false)}
605+
/>
606+
</PromptInputFloat>
607+
) : null}
608+
<PromptInputSuggestions />
609+
<PromptInputTextarea />
610+
{toolbar ? (
611+
<PromptInputToolbar>
612+
<PromptInputAddMenu
613+
items={[
614+
{
615+
id: "files",
616+
label: "Add files",
617+
onSelect: () => record("add:files"),
618+
},
619+
{
620+
id: "link",
621+
label: "Add a link",
622+
disabled: true,
623+
onSelect: () => record("add:link"),
624+
},
625+
{
626+
id: "mention",
627+
label: "Mention an agent",
628+
onSelect: () => record("add:mention"),
629+
},
630+
]}
631+
/>
632+
<PromptInputModeSelect
633+
modes={[
634+
{ id: "ask", label: "Ask", detail: "Answers only" },
635+
{ id: "plan", label: "Plan", detail: "Drafts steps" },
636+
{ id: "agent", label: "Agent", detail: "Takes action" },
637+
]}
638+
value={mode}
639+
onValueChange={(id) => {
640+
record(`mode:${id}`);
641+
setMode(id);
642+
}}
643+
/>
644+
<PromptInputModelSelect
645+
className="ms-auto"
646+
models={[
647+
{ id: "swift", name: "Swift 2", short: "Swift", group: "Fast" },
648+
{ id: "lite", name: "Lite 1", short: "Lite", group: "Fast" },
649+
{
650+
id: "deep",
651+
name: "Deep 4",
652+
short: "Deep",
653+
group: "Thorough",
654+
},
655+
]}
656+
value={model}
657+
onValueChange={(id) => {
658+
record(`model:${id}`);
659+
setModel(id);
660+
}}
661+
/>
662+
<PromptInputButton
663+
aria-label="Voice"
664+
onClick={() => record("voice")}
665+
>
666+
V
667+
</PromptInputButton>
668+
<PromptInputSubmit />
669+
</PromptInputToolbar>
670+
) : (
671+
<PromptInputSubmit />
672+
)}
673+
</PromptInput>
674+
<PromptInputNote>Agents can make mistakes.</PromptInputNote>
675+
<Button onClick={() => setToolbar((shown) => !shown)}>
676+
Toggle toolbar
677+
</Button>
678+
<Button onClick={() => setReplying(true)}>Reply to Ada</Button>
679+
{log}
680+
</div>
681+
);
682+
}
683+
573684
export const scenarios: Record<string, () => ReactNode> = {
574685
smoke: () => <Button>Ready</Button>,
575686
"approval-card": () => <Approval state="idle" />,
@@ -605,4 +716,5 @@ export const scenarios: Record<string, () => ReactNode> = {
605716
"prompt-input": () => <Prompt />,
606717
"prompt-input-attachment": () => <PromptWithAttachment />,
607718
panel: () => <PanelScenario />,
719+
"prompt-input-toolbar": () => <PromptToolbarScenario />,
608720
};

‎e2e/prompt-input-toolbar.spec.ts‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
3+
const events = (page: Page) => page.getByRole("status", { name: "Events" });
4+
const message = (page: Page) => page.getByRole("textbox", { name: "Message" });
5+
const model = (page: Page) => page.getByRole("combobox", { name: "Model" });
6+
const mode = (page: Page) =>
7+
page.getByRole("combobox", { name: "Mode", exact: true });
8+
const add = (page: Page) =>
9+
page.getByRole("button", { name: "Add", exact: true });
10+
11+
function box(page: Page, selector: string) {
12+
return page.locator(selector).evaluate((element) => {
13+
const rect = element.getBoundingClientRect();
14+
return { top: rect.top, bottom: rect.bottom, height: rect.height };
15+
});
16+
}
17+
18+
test.beforeEach(async ({ page }) => {
19+
await page.goto("/prompt-input-toolbar");
20+
});
21+
22+
test("the model picker opens, moves and chooses by keyboard, keeping focus on its trigger", async ({
23+
page,
24+
}) => {
25+
await model(page).focus();
26+
await page.keyboard.press("Enter");
27+
const list = page.getByRole("listbox", { name: "Model" });
28+
await expect(list).toBeVisible();
29+
await expect(model(page)).toBeFocused();
30+
// Opens on the chosen model, then walks the grouped order upward.
31+
const current = page.locator(
32+
"[data-slot='prompt-input-select-option'][data-active]",
33+
);
34+
await expect(current).toHaveText("Deep 4");
35+
await page.keyboard.press("ArrowUp");
36+
await expect(current).toHaveText("Lite 1");
37+
await expect(model(page)).toHaveAttribute(
38+
"aria-activedescendant",
39+
(await current.getAttribute("id")) ?? "",
40+
);
41+
await page.keyboard.press("Enter");
42+
await expect(list).toBeHidden();
43+
await expect(events(page)).toHaveText("model:lite");
44+
await expect(model(page)).toHaveText("Lite");
45+
await expect(model(page)).toBeFocused();
46+
});
47+
48+
test("Escape closes a picker without choosing and leaves focus on its trigger", async ({
49+
page,
50+
}) => {
51+
await mode(page).focus();
52+
await page.keyboard.press("ArrowDown");
53+
await expect(
54+
page.getByRole("listbox", { name: "Mode", exact: true }),
55+
).toBeVisible();
56+
await page.keyboard.press("End");
57+
await page.keyboard.press("Escape");
58+
await expect(
59+
page.getByRole("listbox", { name: "Mode", exact: true }),
60+
).toBeHidden();
61+
await expect(mode(page)).toBeFocused();
62+
await expect(mode(page)).toHaveText("Ask");
63+
await expect(events(page)).toHaveText("");
64+
65+
// Space opens and chooses too.
66+
await page.keyboard.press(" ");
67+
await page.keyboard.press("ArrowDown");
68+
await page.keyboard.press(" ");
69+
await expect(events(page)).toHaveText("mode:plan");
70+
await expect(
71+
page.getByRole("listbox", { name: "Mode", exact: true }),
72+
).toBeHidden();
73+
await expect(mode(page)).toBeFocused();
74+
});
75+
76+
test("a picker chooses by pointer and closes on a press outside", async ({
77+
page,
78+
}) => {
79+
await model(page).click();
80+
await page.getByRole("option", { name: "Swift 2" }).click();
81+
await expect(events(page)).toHaveText("model:swift");
82+
await expect(model(page)).toHaveText("Swift");
83+
84+
await model(page).click();
85+
await expect(page.getByRole("listbox", { name: "Model" })).toBeVisible();
86+
await page.getByRole("button", { name: "Reply to Ada" }).click();
87+
await expect(page.getByRole("listbox", { name: "Model" })).toBeHidden();
88+
});
89+
90+
test("the add menu opens, moves past a disabled item and chooses by keyboard", async ({
91+
page,
92+
}) => {
93+
await add(page).focus();
94+
await page.keyboard.press("Enter");
95+
const menu = page.getByRole("menu", { name: "Add" });
96+
await expect(menu).toBeVisible();
97+
await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused();
98+
await page.keyboard.press("ArrowDown");
99+
await expect(
100+
page.getByRole("menuitem", { name: "Mention an agent" }),
101+
).toBeFocused();
102+
await page.keyboard.press("ArrowDown");
103+
await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused();
104+
await page.keyboard.press("ArrowUp");
105+
await page.keyboard.press("Enter");
106+
await expect(menu).toBeHidden();
107+
await expect(events(page)).toHaveText("add:mention");
108+
await expect(add(page)).toBeFocused();
109+
});
110+
111+
test("Escape closes the add menu and returns focus; Tab closes it and moves on", async ({
112+
page,
113+
}) => {
114+
await add(page).focus();
115+
await page.keyboard.press("ArrowUp");
116+
await expect(
117+
page.getByRole("menuitem", { name: "Mention an agent" }),
118+
).toBeFocused();
119+
await page.keyboard.press("Escape");
120+
await expect(page.getByRole("menu", { name: "Add" })).toBeHidden();
121+
await expect(add(page)).toBeFocused();
122+
123+
await page.keyboard.press("ArrowDown");
124+
await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused();
125+
await page.keyboard.press("Tab");
126+
await expect(page.getByRole("menu", { name: "Add" })).toBeHidden();
127+
await expect(mode(page)).toBeFocused();
128+
await expect(events(page)).toHaveText("");
129+
});
130+
131+
test("the toolbar takes its own row, and without it the box is one row", async ({
132+
page,
133+
}) => {
134+
const field = await box(page, "[data-slot='prompt-input-field']");
135+
const toolbar = await box(page, "[data-slot='prompt-input-toolbar']");
136+
expect(toolbar.top).toBeGreaterThanOrEqual(field.bottom);
137+
138+
await page.getByRole("button", { name: "Toggle toolbar" }).click();
139+
await expect(page.locator("[data-slot='prompt-input-toolbar']")).toHaveCount(
140+
0,
141+
);
142+
const form = await box(page, "[data-slot='prompt-input']");
143+
const single = await box(page, "[data-slot='prompt-input-field']");
144+
const submit = await box(page, "[data-slot='prompt-input-submit']");
145+
// One row: the send button sits beside the text, not under it.
146+
expect(submit.top).toBeLessThan(single.bottom);
147+
expect(form.height).toBeLessThan(field.height + toolbar.height);
148+
});
149+
150+
test("the float holds the reply chip above the box and steps aside for a popup", async ({
151+
page,
152+
}) => {
153+
const float = page.locator("[data-slot='prompt-input-float']");
154+
const chip = page.locator("[data-slot='prompt-input-context']");
155+
await expect(chip).toContainText("Replying to Ada");
156+
const floatBox = await box(page, "[data-slot='prompt-input-float']");
157+
const form = await box(page, "[data-slot='prompt-input']");
158+
expect(floatBox.bottom).toBeLessThanOrEqual(form.top);
159+
await expect(float).toHaveCSS("visibility", "visible");
160+
161+
await model(page).click();
162+
await expect(float).toHaveCSS("visibility", "hidden");
163+
await page.keyboard.press("Escape");
164+
await expect(float).toHaveCSS("visibility", "visible");
165+
166+
await message(page).pressSequentially("@a");
167+
await expect(page.getByRole("listbox", { name: "Mentions" })).toBeVisible();
168+
await expect(float).toHaveCSS("visibility", "hidden");
169+
await page.keyboard.press("Escape");
170+
await expect(float).toHaveCSS("visibility", "visible");
171+
172+
await page.getByRole("button", { name: "Clear" }).click();
173+
await expect(chip).toHaveCount(0);
174+
});
175+
176+
test("toolbar buttons never submit the prompt", async ({ page }) => {
177+
await message(page).fill("draft");
178+
await page.getByRole("button", { name: "Voice" }).click();
179+
await expect(events(page)).toHaveText("voice");
180+
await expect(message(page)).toHaveValue("draft");
181+
});

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,4 @@ export * from "./ui/conversation.js";
132132
export * from "./ui/conversation-divider.js";
133133
export * from "./ui/prompt-input.js";
134134
export * from "./ui/panel.js";
135+
export * from "./ui/prompt-input-toolbar.js";

0 commit comments

Comments
 (0)