diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts index 3631dc05..310d61fe 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -143,6 +143,78 @@ test.describe("getting around", () => { await page.waitForTimeout(1500); }); + test.describe("the composer's menu", () => { + // The gallery's first composer: it takes files, commands and mentions, and has settings. At + // 1280 pixels its box is about 570 wide; at 390, about 310. + const open = async (page: import("@playwright/test").Page, width: number) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto("/app/gallery"); + const tile = page.locator('section[aria-label="Composer"]'); + return { tile, button: tile.getByRole("button", { name: "Composer menu" }) }; + }; + + test("opens upward from its button, the arrows move through it, and Escape closes it onto the button", async ({ + page, + }, info) => { + test.skip(info.project.name !== "dev", "the gallery exists only in development"); + const { tile, button } = await open(page, 1280); + await button.click(); + const menu = tile.getByRole("menu"); + await expect(menu.getByRole("menuitem", { name: "Attach files" })).toBeFocused(); + const menuBox = await menu.boundingBox(); + const buttonBox = await button.boundingBox(); + expect((menuBox?.y ?? 0) + (menuBox?.height ?? 0)).toBeLessThanOrEqual(buttonBox?.y ?? 0); + await page.keyboard.press("ArrowUp"); + await expect(menu.getByRole("menuitem", { name: "Session settings" })).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(menu.getByRole("menuitem", { name: "Attach files" })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(button).toBeFocused(); + }); + + test("a row that opens a picker hands focus back to the menu button when the picker closes", async ({ + page, + }, info) => { + test.skip(info.project.name !== "dev", "the gallery exists only in development"); + const { tile, button } = await open(page, 1280); + await button.focus(); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await expect(tile.getByRole("menuitem", { name: "Commands" })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(tile.getByRole("dialog", { name: "Commands" })).toBeVisible(); + await expect(tile.getByRole("menu")).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(tile.getByRole("dialog", { name: "Commands" })).toHaveCount(0); + await expect(button).toBeFocused(); + }); + + test("in a wide composer the settings are chips beside the button; in a narrow one they are rows in the menu", async ({ + page, + }, info) => { + test.skip(info.project.name !== "dev", "the gallery exists only in development"); + const wide = await open(page, 1280); + await expect(wide.tile.getByRole("button", { name: "Model: Claude Opus 5.5" })).toBeVisible(); + await wide.button.click(); + await expect(wide.tile.getByRole("menuitem", { name: /^Model/ })).toHaveCount(0); + + const narrow = await open(page, 390); + await expect(narrow.tile.getByRole("button", { name: "Model: Claude Opus 5.5" })).toHaveCount( + 0, + ); + await expect(narrow.tile.getByRole("textbox", { name: "Message" })).toHaveAttribute( + "placeholder", + "Message the agent", + ); + await narrow.button.click(); + const model = narrow.tile.getByRole("menuitem", { name: "Model: Claude Opus 5.5" }); + await expect(model).toBeVisible(); + await model.click(); + await expect(narrow.tile.getByRole("dialog", { name: "Model" })).toBeVisible(); + }); + }); + test("the sessions page has the same top bar as the workspace page, with the theme button at its right", async ({ page, }, info) => { diff --git a/packages/ui/base/icon-button.tsx b/packages/ui/base/icon-button.tsx index 162ad7d6..a1672cd4 100644 --- a/packages/ui/base/icon-button.tsx +++ b/packages/ui/base/icon-button.tsx @@ -1,10 +1,7 @@ import type { Icon } from "@phosphor-icons/react"; -import type { ButtonHTMLAttributes } from "react"; +import type { ComponentProps } from "react"; -type ButtonProps = Omit< - ButtonHTMLAttributes, - "type" | "title" | "aria-label" | "children" ->; +type ButtonProps = Omit, "type" | "title" | "aria-label" | "children">; /** * A button drawn as an icon alone. `label` is its accessible name and its hover text. The icon is diff --git a/packages/ui/base/media.ts b/packages/ui/base/media.ts index b7d0ac80..e462a931 100644 --- a/packages/ui/base/media.ts +++ b/packages/ui/base/media.ts @@ -1,4 +1,10 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { + type RefObject, + useCallback, + useLayoutEffect, + useState, + useSyncExternalStore, +} from "react"; /** The media query a reader's request for reduced motion matches. */ export const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"; @@ -20,3 +26,22 @@ export function useMediaQuery(query: string): boolean { ); return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches); } + +/** + * Whether the element `ref` holds is narrower than `width` CSS pixels, padding included, kept + * current as it resizes. It is measured before the first paint, so a narrow element is never drawn + * wide first. False until the element is measured, and when rendered outside a browser. + */ +export function useNarrowerThan(ref: RefObject, width: number): boolean { + const [narrow, setNarrow] = useState(false); + useLayoutEffect(() => { + const element = ref.current; + if (element === null) return; + const measure = () => setNarrow(element.clientWidth < width); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [ref, width]); + return narrow; +} diff --git a/packages/ui/composer/composer-menu.tsx b/packages/ui/composer/composer-menu.tsx new file mode 100644 index 00000000..5fb82c3b --- /dev/null +++ b/packages/ui/composer/composer-menu.tsx @@ -0,0 +1,172 @@ +import { CaretRightIcon, ListIcon } from "@phosphor-icons/react"; +import { Fragment, type KeyboardEvent, type ReactNode, useEffect, useId, useRef } from "react"; +import { IconButton } from "../base/icon-button"; +import { stepIndex } from "../overlay/list"; + +/** One row of the composer's menu. */ +export interface MenuEntry { + readonly id: string; + readonly label: string; + readonly icon: ReactNode; + /** A setting's current value: drawn at the row's end, and read as part of the row's name. */ + readonly value?: string; + /** Set for a boolean setting: the row is a checkbox, and choosing it flips the setting. */ + readonly checked?: boolean; + /** Whether choosing the row opens a picker dialog. */ + readonly opensPicker?: boolean; + /** Whether the row cannot be chosen now. It stays in the menu, where the arrow keys reach it. */ + readonly disabled?: boolean; + readonly onChoose: () => void; +} + +const ROWS = '[role="menuitem"], [role="menuitemcheckbox"]'; + +/** + * The menu button at the composer's bottom-left, and the menu it opens upward, after the WAI-ARIA + * menu button pattern. Down or Up on the button opens the menu on its first or last row. In the + * menu, Up and Down move and wrap, and Home and End jump. Escape, Tab and a press outside close it; + * Tab leaves focus on the button rather than moving on. Choosing a row closes the menu with focus + * on the button, so a picker the row opens hands focus back to the button. The non-empty groups + * are drawn with a separator between each; with none there is no button. + */ +export function ComposerMenu({ + groups, + open, + onOpenChange, +}: { + groups: readonly (readonly MenuEntry[])[]; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const triggerId = useId(); + const menuId = useId(); + const wrapper = useRef(null); + const trigger = useRef(null); + const menu = useRef(null); + // The row that takes focus when the menu opens: the last when Up opened it. + const startAt = useRef<"first" | "last">("first"); + // The latest handler, so the effect below runs when the menu opens and closes, not every render. + const change = useRef(onOpenChange); + change.current = onOpenChange; + const shown = groups.filter((group) => group.length > 0); + + const rows = () => [...(menu.current?.querySelectorAll(ROWS) ?? [])]; + const close = () => { + onOpenChange(false); + trigger.current?.focus(); + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: `rows` reads the ref, not render state + useEffect(() => { + if (!open) return; + const all = rows(); + (startAt.current === "last" ? all.at(-1) : all[0])?.focus(); + startAt.current = "first"; + const onPointerDown = (event: PointerEvent) => { + if (!(event.target instanceof Node) || !wrapper.current?.contains(event.target)) + change.current(false); + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [open]); + + if (shown.length === 0) return null; + + const choose = (entry: MenuEntry) => { + if (entry.disabled) return; + close(); + entry.onChoose(); + }; + + const onMenuKey = (event: KeyboardEvent) => { + const all = rows(); + const focused = document.activeElement; + const at = focused instanceof HTMLElement ? all.indexOf(focused) : -1; + const go = (index: number) => { + event.preventDefault(); + all[index]?.focus(); + }; + switch (event.key) { + case "ArrowDown": + return go(stepIndex(at, 1, all.length)); + case "ArrowUp": + return go(stepIndex(Math.max(at, 0), -1, all.length)); + case "Home": + return go(0); + case "End": + return go(all.length - 1); + case "Escape": + case "Tab": + event.preventDefault(); + close(); + return; + } + }; + + const onTriggerKey = (event: KeyboardEvent) => { + if (open || (event.key !== "ArrowDown" && event.key !== "ArrowUp")) return; + event.preventDefault(); + startAt.current = event.key === "ArrowUp" ? "last" : "first"; + onOpenChange(true); + }; + + return ( +
+ (open ? close() : onOpenChange(true))} + onKeyDown={onTriggerKey} + /> + {open ? ( + + ) : null} +
+ ); +} + +function MenuRow({ entry, onChoose }: { entry: MenuEntry; onChoose: (entry: MenuEntry) => void }) { + return ( + + ); +} diff --git a/packages/ui/composer/composer.tsx b/packages/ui/composer/composer.tsx index feec4e2c..0101589f 100644 --- a/packages/ui/composer/composer.tsx +++ b/packages/ui/composer/composer.tsx @@ -1,7 +1,8 @@ import type { AvailableCommand, SessionConfigOption } from "@agentclientprotocol/sdk"; import { ArrowUpIcon, AtIcon, PaperclipIcon, StopIcon } from "@phosphor-icons/react"; -import { IconButton } from "../base/icon-button"; +import { useNarrowerThan } from "../base/media"; import { type AttachLimits, AttachmentChips, useAttachments } from "./attachments"; +import { ComposerMenu, type MenuEntry } from "./composer-menu"; import { Loader, type LoaderMood } from "../base/loader"; import { type KeyboardEvent, @@ -22,7 +23,13 @@ import { } from "../overlay/list"; import type { Place } from "../overlay/modal"; import { CommandPalette } from "../overlay/palette"; -import { arrange, pickedSetting, SessionControls, settingItems } from "./session-controls"; +import { + arrange, + pickedSetting, + SessionControls, + settingEntries, + settingItems, +} from "./session-controls"; /** The commands whose name starts what has been typed after a leading slash, before any argument. */ export function commandsMatching( @@ -52,7 +59,52 @@ const commandItem = (command: AvailableCommand): PickItem => ({ detail: command.description, }); -type Overlay = { optionId: string } | "settings" | "commands" | "mentions" | null; +type Overlay = { optionId: string } | "settings" | "commands" | "mentions" | "menu" | null; + +/** + * Below this width of the composer's box, in CSS pixels, its bar has no room for the setting chips + * beside the menu and send buttons: the chips move into the menu, and the placeholder is shortened. + */ +const NARROW = 480; + +/** + * The first group of the composer's menu: attaching files, and the pickers for commands and + * mentions. Each row is there only when the composer offers it, and disabled while it takes no input. + */ +export function actionEntries( + offers: { attach: boolean; commands: boolean; mentions: boolean; available: boolean }, + choose: { attach: () => void; commands: () => void; mentions: () => void }, +): MenuEntry[] { + const disabled = !offers.available; + const rows: MenuEntry[] = []; + if (offers.attach) + rows.push({ + id: "attach", + label: "Attach files", + icon: , + disabled, + onChoose: choose.attach, + }); + if (offers.commands) + rows.push({ + id: "commands", + label: "Commands", + icon: /, + opensPicker: true, + disabled, + onChoose: choose.commands, + }); + if (offers.mentions) + rows.push({ + id: "mentions", + label: "Mention", + icon: , + opensPicker: true, + disabled, + onChoose: choose.mentions, + }); + return rows; +} export interface ComposerProps { running: boolean; @@ -87,9 +139,10 @@ export interface ComposerProps { /** * Where a person writes to the agent. Enter sends and Shift+Enter starts a new line. A `/` at the * start offers the agent's commands and an `@` offers what can be mentioned, each in a list the - * arrows move through and Enter or Tab picks from. Below the box, small controls show the model, - * mode and thinking level and open a picker for each; the same pickers are reachable from buttons - * for anyone not typing the trigger characters. + * arrows move through and Enter or Tab picks from. Below the box, a menu at the left attaches files, + * opens the same lists for anyone not typing the trigger characters, and opens the other session + * settings. Chips beside it show the model, mode and thinking level and open a picker for each; + * in a box narrower than NARROW they are rows in the menu instead. */ export function Composer({ running, @@ -113,6 +166,7 @@ export function Composer({ const [dismissed, setDismissed] = useState(null); const [overlay, setOverlay] = useState(null); const shell = useRef(null); + const narrow = useNarrowerThan(shell, NARROW); const [placed, setPlaced] = useState(undefined); const box = useRef(null); const listId = useId(); @@ -273,7 +327,7 @@ export function Composer({ onPick: setting, }; } - if (overlay !== null) { + if (overlay !== null && overlay !== "menu") { const option = configOptions.find((o) => o.id === overlay.optionId); if (option !== undefined) { const items = settingItems(option, false); @@ -377,9 +431,11 @@ export function Composer({ } placeholder={ unavailable ?? - (mentions === undefined - ? "Message the agent · / for commands" - : "Message the agent · / for commands · @ to mention") + (narrow + ? "Message the agent" + : mentions === undefined + ? "Message the agent · / for commands" + : "Message the agent · / for commands · @ to mention") } disabled={!available} rows={1} @@ -402,54 +458,49 @@ export function Composer({ : ""}
+ 0, + mentions: mentions !== undefined, + available, + }, + { + attach: () => picker.current?.click(), + commands: () => setOverlay("commands"), + mentions: () => setOverlay("mentions"), + }, + ), + settingEntries(configOptions, narrow, { + onOpen: onSetConfig && setOverlay, + onToggle: onSetConfig, + }), + ]} + open={overlay === "menu"} + onOpenChange={(open) => setOverlay(open ? "menu" : null)} + /> {attach === undefined ? null : ( - <> - picker.current?.click()} - /> - { - attachments.add([...(event.target.files ?? [])]); - event.target.value = ""; - }} - /> - - )} - {commands.length === 0 ? null : ( - + { + attachments.add([...(event.target.files ?? [])]); + event.target.value = ""; + }} + /> )} - {mentions === undefined ? null : ( - setOverlay("mentions")} + {narrow ? null : ( + )} - {running ? ( ); })} - {rest.length === 0 || readOnly ? null : ( - onOpen("settings")} - /> - )} ); } diff --git a/packages/ui/tests/attachments.test.tsx b/packages/ui/tests/attachments.test.tsx index 4c4f4c7a..acc7a713 100644 --- a/packages/ui/tests/attachments.test.tsx +++ b/packages/ui/tests/attachments.test.tsx @@ -55,14 +55,14 @@ describe("which files are taken", () => { }); }); -describe("the composer's paperclip", () => { - test("is there only when the composer takes files", () => { +describe("the composer's file chooser", () => { + test("is there only when the composer takes files, and takes the types it accepts", () => { const without = renderToStaticMarkup( {}} />); - expect(without).not.toContain("Attach files"); + expect(without).not.toContain('type="file"'); const withFiles = renderToStaticMarkup( {}} attach={{ accept: ["image/*"], maxFiles: 3 }} />, ); - expect(withFiles).toContain('aria-label="Attach files"'); + expect(withFiles).toContain('type="file"'); expect(withFiles).toContain('accept="image/*"'); }); }); diff --git a/packages/ui/tests/composer-menu.test.tsx b/packages/ui/tests/composer-menu.test.tsx new file mode 100644 index 00000000..08e53f7b --- /dev/null +++ b/packages/ui/tests/composer-menu.test.tsx @@ -0,0 +1,146 @@ +/** + * The composer's menu: which rows it holds, and its markup. Opening it, the keys that move through + * it and where focus goes are checked in a browser, on the gallery's composer. + */ + +import { describe, expect, test } from "bun:test"; +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { renderToStaticMarkup } from "react-dom/server"; +import { actionEntries, Composer } from "../composer/composer"; +import { ComposerMenu, type MenuEntry } from "../composer/composer-menu"; +import { settingEntries } from "../composer/session-controls"; + +const noop = () => {}; +const choose = { attach: noop, commands: noop, mentions: noop }; +const offers = { attach: true, commands: true, mentions: true, available: true }; +const labels = (rows: MenuEntry[]) => rows.map((row) => row.label); + +const mode: SessionConfigOption = { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: "ask", + options: [ + { value: "ask", name: "Ask" }, + { value: "code", name: "Code" }, + ], +}; +const stream: SessionConfigOption = { + id: "stream", + name: "Stream", + category: "thought_level", + type: "boolean", + currentValue: true, +}; +const budget: SessionConfigOption = { + id: "budget", + name: "Budget", + type: "select", + currentValue: "low", + options: [{ value: "low", name: "Low" }], +}; + +describe("the menu's actions", () => { + test("are the ones the composer offers, in order", () => { + expect(labels(actionEntries(offers, choose))).toEqual(["Attach files", "Commands", "Mention"]); + expect(labels(actionEntries({ ...offers, attach: false, mentions: false }, choose))).toEqual([ + "Commands", + ]); + expect( + actionEntries({ ...offers, attach: false, commands: false, mentions: false }, choose), + ).toEqual([]); + }); + + test("are disabled while the composer takes no input, and enabled otherwise", () => { + expect(actionEntries({ ...offers, available: false }, choose).map((r) => r.disabled)).toEqual([ + true, + true, + true, + ]); + expect(actionEntries(offers, choose).map((r) => r.disabled)).toEqual([false, false, false]); + }); + + test("open a picker for commands and mentions, and the file chooser for files", () => { + expect(actionEntries(offers, choose).map((r) => r.opensPicker ?? false)).toEqual([ + false, + true, + true, + ]); + }); +}); + +describe("the menu's settings", () => { + const onOpen = noop; + + test("in a wide composer, only the options without a chip, behind one row", () => { + expect(labels(settingEntries([mode, budget], false, { onOpen }))).toEqual(["Session settings"]); + expect(settingEntries([mode], false, { onOpen })).toEqual([]); + }); + + test("in a narrow composer, each featured option is a row showing its value", () => { + const rows = settingEntries([mode, stream, budget], true, { onOpen }); + expect(rows.map((r) => [r.label, r.value, r.opensPicker ?? false, r.checked])).toEqual([ + ["Mode", "Ask", true, undefined], + ["Stream", "On", false, true], + ["Session settings", undefined, true, undefined], + ]); + }); + + test("that cannot be changed are rows shown disabled, with no settings row", () => { + const rows = settingEntries([mode, budget], true, {}); + expect(rows.map((r) => [r.label, r.value, r.disabled])).toEqual([["Mode", "Ask", true]]); + expect(settingEntries([mode, budget], false, {})).toEqual([]); + }); +}); + +describe("the menu's markup", () => { + const actions = actionEntries({ ...offers, available: false }, choose); + const settings = settingEntries([mode, stream, budget], true, { onOpen: noop }); + const draw = (open: boolean, groups: MenuEntry[][] = [actions, settings]) => + renderToStaticMarkup(); + + test("closed, it is a button that says it opens a menu", () => { + const html = draw(false); + expect(html).toMatch(/aria-label="Composer menu"[^>]*aria-haspopup="menu"/); + expect(html).toContain('aria-expanded="false"'); + expect(html).not.toContain('role="menu"'); + }); + + test("open, its rows are menu items, separated by group", () => { + const html = draw(true); + expect(html).toContain('aria-expanded="true"'); + expect(html).toMatch(/role="menu" aria-labelledby="[^"]+"/); + expect(html.match(/role="menuitem"/g)).toHaveLength(5); + expect(html.match(/
{ + const html = draw(true); + expect(html).toMatch(/aria-label="Commands" aria-haspopup="dialog" aria-disabled="true"/); + expect(html).toMatch(/role="menuitemcheckbox" aria-checked="true" aria-label="Stream: On"/); + expect(html).toMatch(/aria-label="Mode: Ask" aria-haspopup="dialog" tabindex="-1"/); + }); + + test("with one group there is no separator, and with no rows there is no button", () => { + expect(draw(true, [actions, []])).not.toContain(" { + test("holds the menu button, the featured options' chips and the file chooser", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('aria-label="Composer menu"'); + expect(html).toContain('aria-label="Mode: Ask"'); + expect(html).toMatch(/ { expect(box).toContain('disabled=""'); }); - test("disables the send button, though the box holds text, and the buttons that add files, commands or mentions", () => { - const html = draw("Connecting to the agent…"); - for (const label of ["Send", "Attach files", "Commands", "Mention"]) - expect(tagOf(html, label)).toContain('disabled=""'); + test("disables the send button, though the box holds text", () => { + expect(tagOf(draw("Connecting to the agent…"), "Send")).toContain('disabled=""'); + }); + + test("disables its menu's rows that add files, commands or mentions", () => { + const offers = { attach: true, commands: true, mentions: true, available: false }; + const noop = () => {}; + const rows = actionEntries(offers, { attach: noop, commands: noop, mentions: noop }); + expect(rows.map((row) => [row.label, row.disabled])).toEqual([ + ["Attach files", true], + ["Commands", true], + ["Mention", true], + ]); }); }); describe("a composer given no such reason", () => { test("takes input, with its own placeholder", () => { const html = draw(undefined); - for (const label of ["Message", "Send", "Attach files", "Commands", "Mention"]) + for (const label of ["Message", "Send", "Composer menu"]) expect(tagOf(html, label)).not.toContain("disabled"); expect(tagOf(html, "Message")).toContain('placeholder="Message the agent'); }); diff --git a/packages/ui/tests/overlay.test.tsx b/packages/ui/tests/overlay.test.tsx index a7219ec7..7b162d7a 100644 --- a/packages/ui/tests/overlay.test.tsx +++ b/packages/ui/tests/overlay.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, test } from "bun:test"; import type { AvailableCommand } from "@agentclientprotocol/sdk"; import { renderToStaticMarkup } from "react-dom/server"; -import { Composer, mentionAt } from "../composer/composer"; +import { actionEntries, Composer, mentionAt } from "../composer/composer"; import { filterItems, inDrawnOrder, type PickItem, stepIndex } from "../overlay/list"; import { Modal } from "../overlay/modal"; import { PalettePanel } from "../overlay/palette"; @@ -197,9 +197,9 @@ describe("the composer", () => { expect(running).toContain('aria-label="Stop"'); }); - test("without mentions there is no @ control", () => { - const html = renderToStaticMarkup( {}} />); - expect(html).not.toContain('aria-label="Mention"'); - expect(html).not.toContain('aria-label="Commands"'); + test("without mentions or commands, its menu has no row for either", () => { + const noop = () => {}; + const offers = { attach: false, commands: false, mentions: false, available: true }; + expect(actionEntries(offers, { attach: noop, commands: noop, mentions: noop })).toEqual([]); }); }); diff --git a/packages/ui/tests/render.test.tsx b/packages/ui/tests/render.test.tsx index f137c7fe..261455a8 100644 --- a/packages/ui/tests/render.test.tsx +++ b/packages/ui/tests/render.test.tsx @@ -11,7 +11,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { commandsMatching } from "../composer/composer"; import type { PickItem } from "../overlay/list"; -import { pickedSetting, settingItems } from "../composer/session-controls"; +import { pickedSetting, settingEntries, settingItems } from "../composer/session-controls"; import { Conversation, type RecordsConfig } from "../index"; async function draw(id: string, handlers = false): Promise { @@ -299,10 +299,11 @@ describe("session configuration", () => { expect(html).toContain('Second'); }); - test("the other settings sit behind one settings control, not a control each", async () => { + test("the other settings sit behind one row of the composer's menu, not a control each", async () => { const html = await shown([model, thinking], { onSend: () => {}, onSetConfig: () => {} }); - expect(html).toContain('aria-label="Session settings"'); expect(html).not.toContain("lk-chip-value"); + const rows = settingEntries([model, thinking], false, { onOpen: () => {} }); + expect(rows.map((row) => row.label)).toEqual(["Session settings"]); }); test("without a handler the values are shown and cannot be changed", async () => { diff --git a/packages/ui/ui.css b/packages/ui/ui.css index ec40df82..5edfb99b 100644 --- a/packages/ui/ui.css +++ b/packages/ui/ui.css @@ -1253,9 +1253,9 @@ button.lk-handle:hover { color: var(--lk-accent-text); font-family: var(--lk-font-mono); } +/* One row: in a box too narrow for the chips at their full width, their values shorten. */ .lk-composer-bar { display: flex; - flex-wrap: wrap; align-items: center; gap: 2px; padding: 4px 8px 8px; @@ -1263,6 +1263,87 @@ button.lk-handle:hover { .lk-composer-end { margin-left: auto; } +/* the composer's menu, opening upward from its button at the bar's left */ +.lk-composer-menu { + position: relative; + display: inline-flex; + flex: none; +} +.lk-icon-btn.on { + background: var(--lk-surface-2); + color: var(--lk-text); +} +.lk-menu { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + z-index: 5; + display: flex; + flex-direction: column; + width: max-content; + min-width: 200px; + max-width: min(320px, calc(100vw - 32px)); + padding: 4px; + border: 1px solid var(--lk-line); + border-radius: 10px; + background: var(--lk-panel); + box-shadow: 0 8px 24px rgba(11, 18, 32, 0.14); +} +.lk-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 6px; + background: none; + color: var(--lk-text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} +.lk-menu-item:hover, +.lk-menu-item:focus-visible { + background: var(--lk-accent-soft); + outline: none; +} +.lk-menu-item[aria-disabled="true"] { + color: var(--lk-faint); + cursor: default; +} +.lk-menu-icon { + display: inline-flex; + flex: none; + justify-content: center; + width: 16px; + color: var(--lk-muted); +} +.lk-menu-label { + flex: none; +} +.lk-menu-value { + min-width: 0; + margin-left: auto; + overflow: hidden; + color: var(--lk-muted); + text-overflow: ellipsis; + white-space: nowrap; +} +.lk-menu-more { + flex: none; + color: var(--lk-muted); +} +.lk-menu-label + .lk-menu-more { + margin-left: auto; +} +.lk-menu-sep { + height: 1px; + margin: 4px 6px; + border: 0; + background: var(--lk-line); +} .lk-send { display: inline-flex; align-items: center; @@ -1288,6 +1369,7 @@ button.lk-handle:hover { display: inline-flex; align-items: center; gap: 5px; + min-width: 0; max-width: 220px; height: 28px; padding: 0 8px; @@ -1298,6 +1380,9 @@ button.lk-handle:hover { font: inherit; font-size: 12px; } +.lk-chip-icon { + flex: none; +} .lk-chip-value { overflow: hidden; text-overflow: ellipsis;