From 15cbdd8e9aa79a66bccf1a384edfdcc880c935a0 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Thu, 15 Oct 2026 16:44:23 +0100 Subject: [PATCH] The composer's actions and settings open from a menu at the bar's left The composer's bar held a button each for attaching files, commands, mentions and the session settings, then a chip each for the mode, model and thinking level. In the chat sidebar, whose composer box is 344 pixels wide by default, the bar wrapped onto three rows, and the placeholder wrapped onto a second line that the one-line box cut off. - `ComposerMenu` is a menu button at the bar's left, with a menu that opens upward from it, after the WAI-ARIA menu button pattern: arrows, Home and End move through the rows, Escape, Tab or a press outside closes it, and focus returns to the button. A row that opens a picker leaves focus on the button, so the picker hands focus back to it. - Attach files, Commands, Mention and Session settings are rows of the menu at every width. A row the composer cannot use yet is disabled. - The mode, model and thinking chips stay in the bar while the composer's box is at least 480 pixels wide. Below that width they are rows of the menu showing their values, and the placeholder reads "Message the agent". `useNarrowerThan` measures the box before the first paint. - The bar no longer wraps: the chips' values shorten when space runs out. - `IconButton` passes a `ref` through to its button. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GVjyjkaADPAqLvtnf3xUNs --- packages/app-web/e2e/app.e2e.ts | 72 ++++++++ packages/ui/base/icon-button.tsx | 7 +- packages/ui/base/media.ts | 27 ++- packages/ui/composer/composer-menu.tsx | 172 ++++++++++++++++++ packages/ui/composer/composer.tsx | 159 ++++++++++------ packages/ui/composer/session-controls.tsx | 67 +++++-- packages/ui/tests/attachments.test.tsx | 8 +- packages/ui/tests/composer-menu.test.tsx | 146 +++++++++++++++ .../ui/tests/composer-unavailable.test.tsx | 21 ++- packages/ui/tests/overlay.test.tsx | 10 +- packages/ui/tests/render.test.tsx | 7 +- packages/ui/ui.css | 87 ++++++++- 12 files changed, 689 insertions(+), 94 deletions(-) create mode 100644 packages/ui/composer/composer-menu.tsx create mode 100644 packages/ui/tests/composer-menu.test.tsx 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;