diff --git a/design/composer-menus.md b/design/composer-menus.md new file mode 100644 index 000000000000..2bec601231d2 --- /dev/null +++ b/design/composer-menus.md @@ -0,0 +1,24 @@ +# Composer project and environment menus + +The project and environment controls in the composer share one menu contract. Their glass and +colour tokens remain those of the composer; rows use one 32 px rhythm, one search inset, and the +same columns: icon or kind glyph, primary and secondary text, trailing status or branch affordance, +then selection check. + +Menus open below their trigger so they stay away from the transcript. The shared placement rule +chooses below when it fits, and flips above only when the available space below is smaller than the +menu and the space above is greater. The chosen side is stable while filtering so rows never jump. + +The project list leads with the current or most recently supplied project. Repository worktrees are +grouped under their repository; scratch and temporary folders stay out of the initial list unless +selected, and a search for their path or name surfaces them. This uses structural paths and never +arbitrary-name filtering. A project avatar or repository mark is shown when available. Otherwise a +quiet monogram has no tile and does not compete with the name. + +The environment list leads with the current machine. Local machines use a laptop glyph, SSH hosts a +terminal/server glyph, and cloud machines a cloud glyph. Section headers, search, secondary state, +keyboard selection, and the scrollbar use the same columns and row height as projects. + +Gallery states cover three, eight, and fifteen project rows plus local, SSH, and cloud environments. +The selected layout is this shared-column version because it keeps the name column stable while +preserving readable secondary details and the current check in both light and dark themes. diff --git a/scripts/cmux-next/type-scale-baseline.tsv b/scripts/cmux-next/type-scale-baseline.tsv index 2291d0c2c8e9..40496954426e 100644 --- a/scripts/cmux-next/type-scale-baseline.tsv +++ b/scripts/cmux-next/type-scale-baseline.tsv @@ -14,7 +14,7 @@ webviews/src/agent-session/acpmux/markdownField.css size 4 webviews/src/agent-session/acpmux/mockFixture.ts size 2 webviews/src/agent-session/acpmux/newtab/screen.css size 15 webviews/src/agent-session/acpmux/prototype/prototype.css size 17 -webviews/src/agent-session/acpmux/styles.css size 48 +webviews/src/agent-session/acpmux/styles.css size 47 webviews/src/agent-session/acpmux/subagents/subagents.css size 2 webviews/src/agent-session/acpmux/threadMinimap/threadMinimap.css size 1 webviews/src/gallery/env.ts size 1 diff --git a/webviews/src/agent-session/acpmux/ComposerContext.gallery.tsx b/webviews/src/agent-session/acpmux/ComposerContext.gallery.tsx index 18562d57b8a7..023f1cfa5631 100644 --- a/webviews/src/agent-session/acpmux/ComposerContext.gallery.tsx +++ b/webviews/src/agent-session/acpmux/ComposerContext.gallery.tsx @@ -296,10 +296,23 @@ export default componentEntry>({ props: baseProps, }, "folder-menu": { - note: "The folder popover: a search field over the recent projects (paths on hover), then Add project and Choose folder….", + note: "Project actions stay directly below search, above the recent projects (paths on hover).", + height: 560, props: baseProps, play: openFolder, }, + "folder-long-list": { + note: "Project actions stay below search while a long folder list scrolls; the thumb stays inside the right edge.", + props: { + ...baseProps, + projectChoices: Array.from({ length: 40 }, (_, index) => ({ + cwd: `/demo/review-${index + 1}`, + label: `review-${index + 1}`, + })), + onNoProject: () => undefined, + }, + play: openFolder, + }, "folder-favicons": { note: "Projects with a favicon (public/, app/, the folder itself) show it in their tile; cmux has none and draws a plain folder.", props: baseProps, @@ -411,6 +424,7 @@ export default componentEntry>({ }, "environment-picker": { note: "Preview: the computer is the environment picker, one searchable list in Local, SSH and Cloud sections with ⌥⌘1–9; New Cloud Machine ends the Cloud section and New environment (SSH…, Connect Phone…) comes last. gpu-box is connected (a filled dot), Cloud machine is not (a ring); this Mac has no status.", + height: 560, props: { ...baseProps, environments: true, diff --git a/webviews/src/agent-session/acpmux/ComposerContext.tsx b/webviews/src/agent-session/acpmux/ComposerContext.tsx index 0edeac8a91c2..b65c1a6ca822 100644 --- a/webviews/src/agent-session/acpmux/ComposerContext.tsx +++ b/webviews/src/agent-session/acpmux/ComposerContext.tsx @@ -68,6 +68,7 @@ type Location = { repo?: ProjectRepo; section?: string; disabled?: boolean; + kind?: EnvironmentKind; }; type Computer = Location & { kind: EnvironmentKind; connected?: boolean; state?: string; cloudMachine?: CloudMachine }; const NO_PEER_KINDS: Record = {}; @@ -784,15 +785,22 @@ function LocationPicker({ return ( // tooltip-allow: the row shows the folder's name; the tooltip is its full path - {folder?.label ?? path} - {!allowPath && folder?.detail && {folder.detail}} + {kind === "computer" && } + + {folder?.label ?? path} + {!allowPath && folder?.detail && {folder.detail}} + {suffix && ( <> {" "} diff --git a/webviews/src/agent-session/acpmux/EnvironmentPicker.tsx b/webviews/src/agent-session/acpmux/EnvironmentPicker.tsx index b40143ef0d55..26b4ba4a4637 100644 --- a/webviews/src/agent-session/acpmux/EnvironmentPicker.tsx +++ b/webviews/src/agent-session/acpmux/EnvironmentPicker.tsx @@ -179,7 +179,13 @@ export function EnvironmentPicker({ setAlternate(false); }; }, [open, offersSandbox]); - const ordered = useMemo(() => orderEnvironments(environments), [environments]); + const ordered = useMemo(() => { + const rows = orderEnvironments(environments); + const currentIndex = rows.findIndex((environment) => environment.id === currentRow); + return currentIndex > 0 + ? [rows[currentIndex]!, ...rows.slice(0, currentIndex), ...rows.slice(currentIndex + 1)] + : rows; + }, [environments, currentRow]); const sections = useMemo(() => environmentSections(environments), [environments]); const sectionOf = (environment: Environment) => sections.get(environment.id) ?? "fleet"; const numbers = useMemo( diff --git a/webviews/src/agent-session/acpmux/FolderPopover.tsx b/webviews/src/agent-session/acpmux/FolderPopover.tsx index 38b0976cbeb0..f0a26d5bb1a8 100644 --- a/webviews/src/agent-session/acpmux/FolderPopover.tsx +++ b/webviews/src/agent-session/acpmux/FolderPopover.tsx @@ -18,8 +18,8 @@ export type FolderChoice = { id: string; label: string; repo?: ProjectRepo }; /// another shares that name, a short suffix that tells it apart (projectSuffix.ts); the full path is /// the row's tooltip. A repository's row picks the current folder, else its main checkout, else its /// newest worktree; its other worktrees, newest first, open under it with → (← or the count closes -/// them). The current folder is checked. After the folders: New Worktree, Add project and Choose -/// folder…, each only where the host offers it. The shared Combobox keeps the keyboard in the field: +/// them). The current folder is checked. Actions stay directly under search, before the scrolling +/// folders: No project, New Worktree, Add project and Choose folder, where the host offers them. The shared Combobox keeps the keyboard in the field: /// typing filters by repository, branch and path, and lists a worktree on its own when the words /// name it; ↑/↓ move through the rows and the actions; Return picks; Escape closes back to the /// strip. A typed absolute or `~/` path is offered as typed. `note` is a quiet line under the list: @@ -83,7 +83,7 @@ export function FolderPopover({ () => projectSuffixes(groups.map((group) => ({ cwd: group.lead.id, label: group.name }))), [groups], ); - const rows = useMemo(() => shownRepoRows(groups, query, expanded), [groups, query, expanded]); + const rows = useMemo(() => shownRepoRows(groups, query, expanded, current), [groups, query, expanded, current]); // Each shown folder's row: a repository's (`group` only) or a worktree's under it. const shown = useMemo(() => { const byValue = new Map(); @@ -103,7 +103,7 @@ export function FolderPopover({ }); const typed = query.trim(); const typedPath = /^(?:\/|~\/)/.test(typed) && !shown.has(typed) ? typed : undefined; - // The actions follow the folders in the same list, so the arrows reach them from the field. + // Actions stay directly under search; Tab reaches them and arrows navigate the matching folders. const actions: { value: string; text: string; run(): void }[] = [ ...(onNoProject ? [{ value: ACTION + "none", text: t("project.noProject"), run: onNoProject }] : []), ...(onNewWorktree ? [{ value: ACTION + "worktree", text: t("worktree.new"), run: onNewWorktree }] : []), @@ -114,7 +114,8 @@ export function FolderPopover({ ] : []), ]; - const suggestions = [...shown.keys(), ...(typedPath ? [typedPath] : []), ...actions.map((action) => action.value)]; + const pinned = actions.map((action) => action.value); + const suggestions = [...pinned, ...shown.keys(), ...(typedPath ? [typedPath] : [])]; const close = () => { setOpen(false); setQuery(""); @@ -260,9 +261,14 @@ export function FolderPopover({ { - setOpen(next); - if (!next) setQuery(""); + onOpenChange={(next, details) => { + if (!next && details?.escape) { + escaped.current = true; + close(); + } else { + setOpen(next); + if (!next) setQuery(""); + } }} anchor={open ? anchor : null} label={label} @@ -273,6 +279,8 @@ export function FolderPopover({ > ): Pr /// The project's own icon when the host finds one on this Mac (projectIcon.ts: its GitHub owner's /// avatar, else an icon file in the folder or its repository), else its framework's or language's -/// mark, else a plain folder; an icon WebKit cannot draw falls back to the mark or the folder too. -/// Never letters and never a corner dot (Leo 2026-10-21: "do the icons/favicons/heuristics for icons -/// apply here yet?"; no dots on the rows). +/// mark; an icon WebKit cannot draw falls back to the mark or a quiet monogram. +/// If there is no icon, use a quiet monogram with no tile; never repeat a generic folder tile. export function ProjectBadge({ project }: { project: Project }) { const kind = projectKind(project); const icon = useProjectIcon(kind === "local" ? project.cwd : undefined); @@ -41,27 +40,18 @@ export function ProjectBadge({ project }: { project: Project }) { ) : ( - + )} ); } /// The folder a project with no icon and no mark draws, in the muted text color (the trigger's own). -function FolderMark() { +function MonogramMark({ value }: { value: string }) { + const letter = Array.from(value.trim())[0]?.toUpperCase() ?? "•"; return ( - + ); } diff --git a/webviews/src/agent-session/acpmux/ProjectChooser.gallery.tsx b/webviews/src/agent-session/acpmux/ProjectChooser.gallery.tsx index 99f27c14794a..e1d65e47812b 100644 --- a/webviews/src/agent-session/acpmux/ProjectChooser.gallery.tsx +++ b/webviews/src/agent-session/acpmux/ProjectChooser.gallery.tsx @@ -12,6 +12,7 @@ type Props = { icon: ReactNode; onPick(cwd: string): void; onBrowse?(): void; + galleryVariant?: "aligned" | "airy" | "quiet"; }; const icon = createElement( @@ -46,6 +47,11 @@ const longProjects: Project[] = [ }, { cwd: "/Users/you/src/cmux", label: "cmux" }, ]; +const galleryProjects = (count: number): Project[] => + Array.from({ length: count }, (_, index) => ({ + cwd: `/Users/you/src/project-${index + 1}`, + label: `project-${index + 1}`, + })); const open: Play = async (ctx) => { await ctx.click({ selector: ".acpmux-project-button" }); @@ -59,6 +65,15 @@ const open: Play = async (ctx) => { throw new Error("Project search must keep the composer field style without a native border"); }; +const openFifteen: Play = async (ctx) => { + await open(ctx); + const rows = ctx.document.querySelectorAll('.acpmux-project-menu [role="option"]'); + if (rows.length < 15) throw new Error(`Expected fifteen project rows, found ${rows.length}`); + const list = ctx.document.querySelector(".acpmux-project-menu .ui-combobox-list"); + if (!list || list.scrollHeight <= list.clientHeight) + throw new Error("Fifteen-row gallery must exercise the project scrollbar"); +}; + // The macOS menu gesture: press the pill, drag onto a row, release to pick it. const pressDragRelease: Play = async (ctx) => { await ctx.pointer.down({ selector: ".acpmux-project-button" }); @@ -107,7 +122,13 @@ export default componentEntry({ variants: { closed: { note: "The current project is visible on the trigger; its row is checked when the chooser opens.", - props: { projects, current: projects[0]!.cwd, currentLabel: projects[0]!.label, icon, onPick: () => undefined }, + props: { + projects, + current: projects[0]!.cwd, + currentLabel: projects[0]!.label, + icon, + onPick: () => undefined, + }, }, "open-current": { note: "The current project stays checked while the project menu is open.", @@ -123,7 +144,13 @@ export default componentEntry({ }, "press-drag-release": { note: "Pressing the pill opens the chooser; dragging onto a project and releasing picks it and closes.", - props: { projects, current: projects[0]!.cwd, currentLabel: projects[0]!.label, icon, onPick: () => undefined }, + props: { + projects, + current: projects[0]!.cwd, + currentLabel: projects[0]!.label, + icon, + onPick: () => undefined, + }, play: pressDragRelease, }, "remote-completion": { @@ -147,12 +174,24 @@ export default componentEntry({ }, "no-matches": { note: "A query with no results shows the quiet empty state without changing the anchor.", - props: { projects, current: projects[0]!.cwd, currentLabel: projects[0]!.label, icon, onPick: () => undefined }, + props: { + projects, + current: projects[0]!.cwd, + currentLabel: projects[0]!.label, + icon, + onPick: () => undefined, + }, play: noMatches, }, "typed-path": { note: "An absolute path query offers the typed path as a selectable project.", - props: { projects, current: projects[0]!.cwd, currentLabel: projects[0]!.label, icon, onPick: () => undefined }, + props: { + projects, + current: projects[0]!.cwd, + currentLabel: projects[0]!.label, + icon, + onPick: () => undefined, + }, play: typedPath, }, "long-names": { @@ -167,5 +206,86 @@ export default componentEntry({ }, play: open, }, + "downward-3": { + note: "Chosen layout: the shared placement rule opens below the composer when three projects fit.", + height: 760, + props: { + projects: galleryProjects(3), + current: "/Users/you/src/project-1", + currentLabel: "project-1", + icon, + onPick: () => undefined, + onBrowse: () => undefined, + }, + play: open, + }, + "downward-8": { + note: "Chosen layout: eight rows keep the same aligned icon, text and selection columns while the list scrolls.", + height: 760, + props: { + projects: galleryProjects(8), + current: "/Users/you/src/project-1", + currentLabel: "project-1", + icon, + onPick: () => undefined, + onBrowse: () => undefined, + }, + play: open, + }, + "downward-15": { + note: "Chosen layout: fifteen rows use the contained scrollbar and remain keyboard navigable without clipped content.", + height: 760, + props: { + projects: galleryProjects(15), + current: "/Users/you/src/project-1", + currentLabel: "project-1", + icon, + onPick: () => undefined, + onBrowse: () => undefined, + }, + play: openFifteen, + }, + "variant-aligned-columns": { + note: "Gallery variant A: the selected shared-column layout keeps the icon, name, secondary path and check aligned.", + height: 760, + props: { + projects: galleryProjects(8), + current: "/Users/you/src/project-1", + currentLabel: "project-1", + icon, + galleryVariant: "aligned", + onPick: () => undefined, + onBrowse: () => undefined, + }, + play: open, + }, + "variant-airy-rows": { + note: "Gallery variant B: roomier rows increase vertical breathing space while preserving the same columns.", + height: 760, + props: { + projects: galleryProjects(8), + current: "/Users/you/src/project-1", + currentLabel: "project-1", + icon, + galleryVariant: "airy", + onPick: () => undefined, + onBrowse: () => undefined, + }, + play: open, + }, + "variant-quiet-icons": { + note: "Gallery variant C: brand and fallback icons lose their tile so the project name leads visually.", + height: 760, + props: { + projects: galleryProjects(8), + current: "/Users/you/src/project-1", + currentLabel: "project-1", + icon, + galleryVariant: "quiet", + onPick: () => undefined, + onBrowse: () => undefined, + }, + play: open, + }, }, }); diff --git a/webviews/src/agent-session/acpmux/ProjectChooser.tsx b/webviews/src/agent-session/acpmux/ProjectChooser.tsx index 9d784850c0dd..767cd613f9f5 100644 --- a/webviews/src/agent-session/acpmux/ProjectChooser.tsx +++ b/webviews/src/agent-session/acpmux/ProjectChooser.tsx @@ -12,7 +12,13 @@ import type { ProjectRepo } from "./projectRepos"; /// A folder to start a chat in. `peer` (with its display name `host`) is another machine's folder: /// its row carries a globe and the machine's name, and a pick starts the chat there. `repo` is the /// folder's repository as this Mac's host read it (projectRepos.ts). -export type Project = { cwd: string; label: string; peer?: string; host?: string; repo?: ProjectRepo }; +export type Project = { + cwd: string; + label: string; + peer?: string; + host?: string; + repo?: ProjectRepo; +}; /// One row of the chooser: a project, or Do not work in a project (`project` undefined). type Row = { key: string; project?: Project }; @@ -20,7 +26,7 @@ type Row = { key: string; project?: Project }; const NO_PROJECT_KEY = "\u0000none"; const rowKey = (project: Project) => (project.peer ? `${project.peer}\u0000${project.cwd}` : project.cwd); -/// The project pill on the composer's tray: it opens a menu above the tray with a search field over the +/// The project pill on the composer's tray: it opens a menu below the tray with a search field over the /// projects the user has chats in, newest first. Picking one other than the current /// project starts a new chat there. The search field keeps focus; arrows move the /// highlight, Enter picks and Escape closes back to the pill. @@ -38,8 +44,9 @@ export function ProjectChooser({ onNoProject, noProject = false, projectHost, - side = "top", + side = "bottom", inline = false, + galleryVariant = "aligned", }: { projects: Project[]; current?: string; @@ -56,6 +63,8 @@ export function ProjectChooser({ /// Where the menu opens: above the composer's tray, below a picker at the top of a page. side?: "top" | "bottom"; inline?: boolean; + /// Gallery-only visual alternatives; the shipped default is the aligned-column treatment. + galleryVariant?: "aligned" | "airy" | "quiet"; }) { const t = useT(); const [open, setOpen] = useState(false); @@ -69,16 +78,20 @@ export function ProjectChooser({ const shown = useMemo(() => { const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); - return projects.filter((project) => { + const filtered = projects.filter((project) => { const text = `${project.label} ${project.cwd} ${project.host ?? ""}`.toLowerCase(); return words.every((word) => text.includes(word)); }); - }, [projects, query]); + const currentIndex = filtered.findIndex((project) => rowKey(project) === currentKey); + return currentIndex > 0 + ? [filtered[currentIndex]!, ...filtered.slice(0, currentIndex), ...filtered.slice(currentIndex + 1)] + : filtered; + }, [projects, query, currentKey]); const rows = useMemo(() => { const listed: Row[] = shown.map((project) => ({ key: rowKey(project), project })); - if (onNoProject && !query.trim()) listed.push({ key: NO_PROJECT_KEY }); + if (onNoProject) listed.unshift({ key: NO_PROJECT_KEY }); return listed; - }, [shown, onNoProject, query]); + }, [shown, onNoProject]); const rowsByKey = useMemo(() => new Map(rows.map((row) => [row.key, row])), [rows]); const isCurrent = (key: string) => rowsByKey.has(key) && (key === currentKey || (noProject && key === NO_PROJECT_KEY)); @@ -118,9 +131,11 @@ export function ProjectChooser({ return () => window.removeEventListener("blur", blur); }, [open]); - const suggestions = rows.map((row) => row.key); + const pinned = [...(onNoProject ? [NO_PROJECT_KEY] : []), ...(onBrowse ? [ADD_PROJECT_KEY] : [])]; + const suggestions = [...pinned, ...rows.filter((row) => row.key !== NO_PROJECT_KEY).map((row) => row.key)]; if (typedPath && shown.length === 0) suggestions.push(typedPath); const rowLabel = (key: string) => { + if (key === ADD_PROJECT_KEY) return t("project.new"); const project = rowsByKey.get(key)?.project; return project ? [project.label, project.host, project.cwd].filter(Boolean).join(" ") @@ -155,7 +170,7 @@ export function ProjectChooser({ onOpenChange={(next) => (next ? show() : close(true))} anchor={open ? trigger.current : null} label={t("project.label")} - className="acpmux-menu acpmux-project-menu" + className={`acpmux-menu acpmux-project-menu acpmux-project-menu--${galleryVariant}`} side={side} initialFocus={search} finalFocus={false} @@ -164,8 +179,14 @@ export function ProjectChooser({ { + if (key === ADD_PROJECT_KEY) { + close(false); + setAdding(true); + return; + } const row = rowsByKey.get(key) ?? (!key && !query.trim() @@ -181,12 +202,19 @@ export function ProjectChooser({ label={t("project.search")} placeholder={t("project.search")} inputClassName="acpmux-project-search-field" - listClassName="m-0 list-none p-0" + listClassName="acpmux-project-folders m-0 list-none p-0" itemClassName="acpmux-menu-item rounded-lg data-[highlighted]:bg-hover" itemLabel={rowLabel} isItemCurrent={isCurrent} completionText={(key) => (key === NO_PROJECT_KEY ? "" : rowLabel(key))} renderItem={(key) => { + if (key === ADD_PROJECT_KEY) + return ( + <> + + {t("project.new")} + + ); const row = rowsByKey.get(key); const project = row?.project; const checked = isCurrent(key); @@ -212,23 +240,9 @@ export function ProjectChooser({ }} inline /> - {suggestions.length === 0 && (query.trim() || !onNoProject) && ( + {shown.length === 0 && !typedPath && (
{t(query.trim() ? "project.none" : "project.noneYet")}
)} - {onBrowse && ( - - )} ); } + +const ADD_PROJECT_KEY = "__add_project__"; diff --git a/webviews/src/agent-session/acpmux/composerLocation.css b/webviews/src/agent-session/acpmux/composerLocation.css index 42a201977796..63703d810dad 100644 --- a/webviews/src/agent-session/acpmux/composerLocation.css +++ b/webviews/src/agent-session/acpmux/composerLocation.css @@ -203,20 +203,27 @@ .ui-positioner the pane's tokens), so the composer menu's own absolute placement is undone. Both classes, so this wins over styles.css's .acpmux-menu whichever loads last: losing it puts the popup its own height above the positioner. */ -.acpmux-menu.acpmux-location-menu { position: relative; inset: auto; min-width: 200px; max-width: 320px; max-height: none; overflow: visible; font-size: var(--text-control); letter-spacing: var(--text-control--letter-spacing); } +.acpmux-menu.acpmux-location-menu { position: relative; inset: auto; display: flex; flex-direction: column; min-width: 200px; max-width: 320px; max-height: min(420px, calc(100vh - 24px), calc(var(--available-height, 100vh) - 24px)); overflow: hidden; font-size: var(--text-control); letter-spacing: var(--text-control--letter-spacing); } +.acpmux-location-menu > div { display: flex; flex: 1; flex-direction: column; min-height: 0; } /* The recent folders (and the folder field's suggestions) scroll; Choose folder… stays in view under them. A location menu keeps the side it chose when it opened (locationPopover.ts useLocationPopover), so the list fits the room the positioner reports there, less Choose folder… - and the menu's padding, but never under two rows. */ + and the menu's padding. */ +.acpmux-location-menu .ui-combobox-viewport { + max-height: max(0px, min(260px, 45vh, calc(100vh - 60px), calc(var(--available-height, 100vh) - 60px))); + overflow: auto; +} .acpmux-location-folders { - /* Its height by design: a window too small for it sends the menu past its edge (popupHost.ts). */ + /* Keep the menu inside the available viewport; a short pane scrolls rows instead of pushing + the popup below the frame or leaving an orphan section header visible. */ --popup-scroll-cap: 260px; - max-height: max(88px, min(260px, 45vh, calc(var(--available-height, 100vh) - 60px))); + max-height: max(0px, min(260px, 45vh, calc(var(--available-height, 100vh) - 60px))); overflow: auto; } .acpmux-location-menu .acpmux-menu-item { width: 100%; text-align: left; } .acpmux-location-menu .ui-menu-item[data-highlighted] { background: var(--acpmux-menu-highlight); } .acpmux-location-menu .acpmux-menu-description { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.acpmux-location-menu .acpmux-location-search { height: var(--ui-row-height, var(--cmux-control-height, 32px)); margin: 0; padding-inline: var(--ui-row-padding-inline, 8px); box-sizing: border-box; } .acpmux-location-menu .ui-separator { flex: none; height: 1px; border: 0; margin: 4px 6px; background: color-mix(in srgb, var(--agent-text) 12%, transparent); } /* A started chat's Choose folder… under the folder field's rows (dogfood 09): a plain row, not a button face. */ .acpmux-location-choose { border: 0; background: none; color: inherit; font: inherit; } @@ -1018,8 +1025,7 @@ padding-inline: calc(var(--acpmux-top-strip-edge) - var(--acpmux-tray-inset)); } -/* The folder popover (FolderPopover.tsx): a search field over the folders, then New Worktree, Add - project and Choose folder…. A row is its check, its project's badge and its name, with a short +/* The folder popover (FolderPopover.tsx): search, pinned project actions, then scrolling folders. A row is its check, its project's badge and its name, with a short suffix only when another project shares the name; the full path is its tooltip. */ .acpmux-folder-popover { display: flex; flex-direction: column; min-width: 240px; } .acpmux-folder-popover .acpmux-location-search { @@ -1027,8 +1033,9 @@ box-sizing: border-box; width: 100%; height: 30px; - margin: 0 0 4px; - padding: 0 8px; + height: var(--ui-row-height, var(--cmux-control-height, 32px)); + margin: 0; + padding: 0 var(--ui-row-padding-inline, 8px); border: 0; border-bottom: .5px solid color-mix(in srgb, var(--agent-text) 12%, transparent); border-radius: 0; @@ -1038,21 +1045,15 @@ outline: none; } .acpmux-folder-popover .acpmux-location-search::placeholder { color: var(--agent-muted); opacity: 1; } -.acpmux-folder-popover .acpmux-location-folders { margin: 0; padding: 0; list-style: none; } +.acpmux-folder-popover .acpmux-location-folders { margin: 0; padding: 0; list-style: none; height: 0; flex: 1 1 0; min-height: 0; max-height: max(0px, min(260px, 45vh, calc(100vh - 60px), calc(var(--available-height, 100vh) - 60px))); overflow: hidden; } +/* EnvironmentPicker shares this shell but has no pinned ScrollArea; its list must retain wheel scrolling. */ +.acpmux-folder-popover.acpmux-env-popover .acpmux-location-folders { overflow: auto; } .acpmux-folder-popover .acpmux-menu-item { display: flex; align-items: center; min-width: 0; border-radius: var(--ui-row-radius, 6px); } .acpmux-location-row, -.acpmux-location-action { display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0; } -/* The actions after the folders: a hairline above the first. */ -.acpmux-folder-popover .acpmux-menu-item:has(> .acpmux-location-action[data-first]) { position: relative; margin-top: 9px; } -.acpmux-folder-popover .acpmux-menu-item:has(> .acpmux-location-action[data-first])::before { - content: ""; - position: absolute; - top: -5px; - left: 6px; - right: 6px; - height: 1px; - background: color-mix(in srgb, var(--agent-text) 12%, transparent); -} +.acpmux-location-action { display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0; min-height: 32px; box-sizing: border-box; } +.acpmux-location-row > .acpmux-menu-text, +.acpmux-location-action > .acpmux-menu-text { min-width: 0; } +.acpmux-folder-popover .ui-combobox-pinned::after { content: ""; display: block; height: 1px; margin: 4px 6px 0; background: color-mix(in srgb, currentColor 12%, transparent); } .acpmux-folder-popover .acpmux-menu-item[data-highlighted] { background: var(--acpmux-menu-highlight); } .acpmux-location-check { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 16px; min-height: 16px; margin-left: auto; color: var(--agent-text); } /* The name and its suffix on one line, cut at the end: "cmux · cg-lane-adopt". */ @@ -1342,7 +1343,8 @@ letter-spacing: var(--text-caption--letter-spacing); } .acpmux-env-row .acpmux-env-key + .acpmux-location-check { margin-left: 6px; } -.acpmux-env-popover .acpmux-env-icon { flex: none; color: var(--agent-muted); } +.acpmux-env-popover .acpmux-env-icon, +.acpmux-location-menu .acpmux-env-icon { flex: none; width: 20px; height: 20px; padding: var(--ds-space-1); box-sizing: border-box; color: var(--agent-muted); } .acpmux-env-popover .acpmux-env-icon[data-kind="ssh"] { color: var(--agent-ansi-2, currentColor); } .acpmux-env-popover .acpmux-env-icon[data-kind="cloud"] { color: var(--agent-ansi-4, currentColor); } /* A remote machine's status, as acpmux last said: a filled dot when connected, a ring when not. */ diff --git a/webviews/src/agent-session/acpmux/locationPopover.ts b/webviews/src/agent-session/acpmux/locationPopover.ts index cfe320caae18..e1e9528c1f6a 100644 --- a/webviews/src/agent-session/acpmux/locationPopover.ts +++ b/webviews/src/agent-session/acpmux/locationPopover.ts @@ -3,12 +3,11 @@ import type { UiVirtualAnchor } from "../../ui/Popover"; import { useOpenSide } from "../../ui/openSide"; import { registerPicker } from "./pickerOpeners"; -/// The location menus open next to their control: on its top side, the way the model menu opens -/// above its chip (Lawrence 2026-10-08: "popover is not next to the button"; this replaces -/// cx-yrgh's anchor at the card's top edge, which left the card's height between the two). Every -/// location menu decides its side once, when it opens, by the shared rule (`useOpenSide`): above -/// unless the menu's full height does not fit there and below has more room. It keeps that side -/// while open (a filter shrinking the folder list never moves it, cx-ncc.53) and fits the room. +/// The location menus open next to their control, below it and away from the chat like the other +/// composer dropdowns. Every menu decides its side once when it opens by the shared rule +/// (`useOpenSide`): below when the full menu fits, flipping above only when below has less room. +/// It keeps that side while open (a filter shrinking the folder list never moves it, cx-ncc.53) +/// and fits the room. export function useLocationPopover() { const control = useRef(null); const anchor = useMemo( @@ -20,7 +19,7 @@ export function useLocationPopover() { }), [], ); - return { control, anchor, ...useOpenSide(control, "top", LOCATION_MENU_HEIGHT) }; + return { control, anchor, ...useOpenSide(control, "bottom", LOCATION_MENU_HEIGHT) }; } /// A location menu at its tallest: the folder list's cap (composerLocation.css), a field or diff --git a/webviews/src/agent-session/acpmux/projectRepos.test.ts b/webviews/src/agent-session/acpmux/projectRepos.test.ts index ba933574eb7d..695256681f0a 100644 --- a/webviews/src/agent-session/acpmux/projectRepos.test.ts +++ b/webviews/src/agent-session/acpmux/projectRepos.test.ts @@ -184,6 +184,8 @@ test("one row per repository: every clone and lane checkout of one GitHub reposi ["Projects", 1], ["cmux", 6], ["leo-workspace", 1], + ["cmux-live-block.noindex", 1], + ["scratch", 1], ]); const cmuxRow = groups[1]!; // The main clone leads; the rest are its branches, newest first. @@ -199,13 +201,18 @@ test("one row per repository: every clone and lane checkout of one GitHub reposi expect(belowCheckout(cmuxRow.members.find((member) => member.label === "webviews")!)).toBe("webviews"); }); -test("scratch folders in no repository and the home folder are left out unless current", () => { +test("scratch folders stay searchable while the unfiltered list leaves them out", () => { const ids = (current?: string) => groupByRepo(picker, current).flatMap((group) => group.members.map((m) => m.id)); - expect(ids(leo)).not.toContain(`${leo}/cmux-live-block.noindex`); + expect(ids(leo)).toContain(`${leo}/cmux-live-block.noindex`); expect(ids(leo)).not.toContain("/Users/leo"); - expect(ids(leo)).not.toContain("/Users/leo/Library/Caches/x.noindex/scratch"); + expect(ids(leo)).toContain("/Users/leo/Library/Caches/x.noindex/scratch"); expect(ids("/Users/leo")).toContain("/Users/leo"); expect(ids(`${leo}/cmux-live-block.noindex`)).toContain(`${leo}/cmux-live-block.noindex`); + const groups = groupByRepo(picker, leo); + expect(shownRepoRows(groups, "", new Set(), leo).map((row) => row.group.name)).not.toContain( + "cmux-live-block.noindex", + ); + expect(shownRepoRows(groups, "scratch", new Set(), leo).map((row) => row.group.name)).toContain("scratch"); }); test("a lane checkout's guess joins only an unambiguous repository of that name", () => { @@ -265,6 +272,11 @@ test("the flat pickers list each repository once, by its lead, and no scratch fo "/Users/me/Projects/checkouts/cmux/lane-a", "/Users/me/notes", ]); + expect(projectRows(projects, "/Users/me/Projects/cmux-live-block.noindex").map((project) => project.cwd)).toEqual([ + "/Users/me/Projects/cmux-live-block.noindex", + "/Users/me/Projects/cmux", + "/Users/me/notes", + ]); }); test("projects a chat used come first, newest chat first; the rest keep their order", () => { @@ -276,7 +288,7 @@ test("projects a chat used come first, newest chat first; the rest keep their or // ("leo--ask-cost-dollar" lived in `checkouts/cmux/`, "cmux-new-tab-order.noindex"). A main checkout, // three Git worktrees (the host read their `.git` files), a second clone of the same GitHub // repository, a `.noindex` folder, the lane mirror, and lane checkouts the host said nothing about. -test("one row per repository with its worktrees under it, and no scratch rows", () => { +test("one row per repository with its worktrees under it, while flat rows omit scratch", () => { const p = "/Users/me/Projects"; const app = { name: "app", github: "acme/app" }; const worktree = (id: string, branch: string): RepoFolder => ({ @@ -302,6 +314,8 @@ test("one row per repository with its worktrees under it, and no scratch rows", const groups = groupByRepo(fixture); expect(groups.map((group) => [group.name, group.lead.id])).toEqual([ ["app", `${p}/app`], + ["app-new-tab-order.noindex", `${p}/app-new-tab-order.noindex`], + ["hooks", `${p}/.wt-mirrors/acme/app.git/hooks`], ["notes", `${p}/notes`], ]); expect(groups[0]!.members.slice(1).map(worktreeName)).toEqual([ @@ -322,10 +336,20 @@ test("a folder named like a lane but outside a lane root stays listed", () => { const plain: RepoFolder[] = [ { id: "/Users/me/leo--feature", label: "leo--feature" }, { id: "/Users/me/Projects/design--notes", label: "design--notes" }, + { id: "/Users/me/session", label: "session" }, + { id: "/Users/me/ffffff", label: "ffffff" }, + { id: "/Users/me/wt-foldC", label: "wt-foldC" }, + { id: "/Users/me/summary-menu-visual-triage", label: "summary-menu-visual-triage" }, + { id: "/Users/me/a1b2c3", label: "a1b2c3" }, ]; expect(groupByRepo(plain).map((group) => group.lead.id)).toEqual([ "/Users/me/leo--feature", "/Users/me/Projects/design--notes", + "/Users/me/session", + "/Users/me/ffffff", + "/Users/me/wt-foldC", + "/Users/me/summary-menu-visual-triage", + "/Users/me/a1b2c3", ]); - expect(projectRows(plain.map((folder) => ({ cwd: folder.id, label: folder.label }))).length).toBe(2); + expect(projectRows(plain.map((folder) => ({ cwd: folder.id, label: folder.label })))).toHaveLength(7); }); diff --git a/webviews/src/agent-session/acpmux/projectRepos.ts b/webviews/src/agent-session/acpmux/projectRepos.ts index bb6e1a04fb8f..5f8c80d9f41b 100644 --- a/webviews/src/agent-session/acpmux/projectRepos.ts +++ b/webviews/src/agent-session/acpmux/projectRepos.ts @@ -71,7 +71,7 @@ export function guessProjectRepo(cwd: string): ProjectRepo | undefined { const GUESSED = "\u0000repo:"; /// A folder the pickers offer: its path (`id`), name and, when known, its repository. -export type RepoFolder = { id: string; label: string; repo?: ProjectRepo }; +export type RepoFolder = { id: string; label: string; repo?: ProjectRepo; scratch?: boolean }; /// One repository's folders, in the order given (newest first). `lead` is what its row picks: the /// current folder when it is one of them, else the main checkout, else the newest. @@ -148,8 +148,13 @@ export function groupByRepo(folders: readonly T[], current }; const resolved = folders.flatMap((folder): T[] => { const repo = resolve(folder); - if (folder.id !== current && (isHomeFolder(folder.id) || (!repo && isScratchFolder(folder.id)))) return []; - return [repo && repo !== folder.repo ? { ...folder, repo } : folder]; + if (folder.id !== current && isHomeFolder(folder.id)) return []; + return [ + { + ...(repo && repo !== folder.repo ? { ...folder, repo } : folder), + ...(isScratchFolder(folder.id) ? { scratch: true } : {}), + }, + ]; }); const guessed = new Set( resolved.flatMap((folder) => (folder.repo?.root.startsWith(GUESSED) ? [folder.repo.root] : [])), @@ -166,7 +171,7 @@ export function groupByRepo(folders: readonly T[], current const key = keyOf(folder); groups.set(key, [...(groups.get(key) ?? []), folder]); } - return [...groups].map(([key, members]) => { + const result = [...groups].map(([key, members]) => { const repo = members.map((member) => member.repo).find(Boolean); const lead = members.find((member) => member.id === current) ?? @@ -181,6 +186,12 @@ export function groupByRepo(folders: readonly T[], current members, }; }); + // Keep the current project at the top while preserving the host's recency order for the rest. + if (!current) return result; + const currentIndex = result.findIndex((group) => group.members.some((member) => member.id === current)); + return currentIndex > 0 + ? [result[currentIndex]!, ...result.slice(0, currentIndex), ...result.slice(currentIndex + 1)] + : result; } /// One project per repository for a picker that lists no worktrees (the New Tab hero and @@ -193,9 +204,15 @@ export function projectRows group.lead.project); + // Scratch folders stay available to the searchable project menu, but do not become + // ordinary New Tab project rows. Keep the selected scratch checkout visible so a + // current chat never loses its project context. + return groupByRepo(folders, current) + .filter((group) => !group.lead.scratch || group.lead.id === current) + .map((group) => group.lead.project); } /// The projects in the order they were last used: those a chat used, newest chat first, then the @@ -240,13 +257,18 @@ export function shownRepoRows( groups: readonly RepoGroup[], query: string, expanded: ReadonlySet, + current?: string, ): { group: RepoGroup; worktrees: T[] }[] { const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); if (words.length === 0) - return groups.map((group) => ({ - group, - worktrees: expanded.has(group.key) ? group.members.filter((member) => member !== group.lead) : [], - })); + return groups + .filter( + (group) => !group.lead.scratch || group.lead.id === current || group.members.some((member) => !member.scratch), + ) + .map((group) => ({ + group, + worktrees: expanded.has(group.key) ? group.members.filter((member) => member !== group.lead) : [], + })); const rows: { group: RepoGroup; worktrees: T[] }[] = []; for (const group of groups) { const shared = `${group.name} ${group.github ?? ""}`.toLowerCase(); diff --git a/webviews/src/agent-session/acpmux/styles.css b/webviews/src/agent-session/acpmux/styles.css index e2b41b089472..d04337d27001 100644 --- a/webviews/src/agent-session/acpmux/styles.css +++ b/webviews/src/agent-session/acpmux/styles.css @@ -292,7 +292,13 @@ .acpmux-project-button{display:inline-flex;align-items:center;gap:5px;height:24px;max-width:220px;padding:0;border:0;border-radius:3px;background:none;color:var(--agent-muted);font:inherit;cursor:pointer;white-space:nowrap} .acpmux-project-button:hover,.acpmux-project-button[aria-expanded="true"]{color:var(--agent-text);background:none} .acpmux-project-button:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:1px}@media (prefers-reduced-motion:no-preference){.acpmux-project-button{transition:background-color var(--agent-motion-hover) var(--agent-ease-out)}}.acpmux-project-button:active{background:color-mix(in srgb,var(--agent-text) 20%,transparent)} -.ui-popup.acpmux-project-menu{position:relative;inset:auto;width:300px;max-width:calc(100vw - 32px);font-size:13px;--acpmux-project-menu-edge:4px} +.ui-popup.acpmux-project-menu{position:relative;inset:auto;display:flex;flex-direction:column;width:300px;max-width:calc(100vw - 32px);max-height:min(520px,calc(100vh - 24px),calc(var(--available-height,100vh) - 24px));overflow:hidden;font-size:var(--text-control);letter-spacing:var(--text-control--letter-spacing);--acpmux-project-menu-edge:4px} +.acpmux-project-menu>div{display:flex;flex:0 1 auto;flex-direction:column;min-height:0} +.acpmux-project-menu .ui-combobox-list{display:flex;flex:1 1 auto;flex-direction:column;min-height:0;overflow:hidden} +.acpmux-project-menu .ui-combobox-list:not(.ui-combobox-pinned-list){overflow:auto} +.acpmux-project-menu .ui-combobox-pinned-list{display:flex;flex-direction:column;min-height:0} +.acpmux-project-menu .ui-combobox-scroll{flex:1 1 auto;max-height:calc(100vh - 100px);min-height:0;overflow:hidden} +.acpmux-project-menu .ui-combobox-viewport{max-height:calc(100vh - 100px);overflow:auto} .acpmux-project-menu .acpmux-menu-item{padding-inline:var(--acpmux-project-menu-edge)} .acpmux-project-menu .acpmux-project-search{padding-inline:var(--acpmux-project-menu-edge)} .acpmux-project-menu .acpmux-project-browse{padding-inline:var(--acpmux-project-menu-edge)} @@ -301,6 +307,8 @@ and the rows line up. A framework's or language's mark (ProjectBadge.tsx) takes its brand color; a project with neither draws a muted folder. No corner dot on any row (Leo 2026-10-21). */ .acpmux-project-badge{position:relative;display:inline-flex;align-items:center;justify-content:center;flex:none;width:20px;height:20px;border-radius:6px;background:color-mix(in srgb,var(--agent-text) 7%,transparent);color:var(--agent-muted);line-height:1} +.acpmux-project-badge[data-project-folder]{background:none;border-radius:0;width:20px;color:var(--agent-muted);font-size:var(--text-caption);font-weight:600;letter-spacing:0} +.acpmux-project-monogram{display:inline-flex;align-items:center;justify-content:center;line-height:var(--text-caption--line-height)} /* A brand color is pulled a quarter toward the text color: darker on a light tile, lighter on a dark one, so a pale mark (React's cyan) and a deep one (Flutter's blue) both read in either theme. */ .acpmux-project-badge[data-project-glyph]{color:color-mix(in srgb,var(--acpmux-project-badge) 72%,var(--agent-text))} @@ -313,7 +321,11 @@ .acpmux-project-search input{flex:1;min-width:0;height:100%;padding:0;border:0;background:none;color:var(--agent-text);font:inherit;outline:none} .acpmux-project-search input::placeholder{color:var(--acpmux-composer-dim)} /* The shared Combobox has the same project field as the old inline chooser, without a wrapper. */ -.acpmux-project-menu .acpmux-project-search-field{display:block;box-sizing:border-box;appearance:none;width:100%;min-width:0;height:32px;margin:0 0 4px;padding:0 var(--acpmux-project-menu-edge);border:0;border-bottom:.5px solid var(--acpmux-composer-edge);border-radius:0;background:none;color:var(--agent-text);font:inherit;outline:none} +.acpmux-project-menu .acpmux-project-search-field{display:block;box-sizing:border-box;appearance:none;width:100%;min-width:0;height:var(--ui-row-height,var(--cmux-control-height,32px));margin:0;padding:0 var(--ui-row-padding-inline,var(--acpmux-project-menu-edge));border:0;border-bottom:.5px solid var(--acpmux-composer-edge);border-radius:0;background:none;color:var(--agent-text);font:inherit;outline:none} +.acpmux-project-menu--airy .acpmux-menu-item{min-height:calc(var(--ui-row-height,var(--cmux-control-height,32px)) + var(--ds-space-1));padding-block:var(--ds-space-1)} +.acpmux-project-menu--airy .acpmux-project-search-field{height:calc(var(--ui-row-height,var(--cmux-control-height,32px)) + var(--ds-space-1))} +.acpmux-project-menu--quiet .acpmux-project-badge{background:none;border-radius:0} +.acpmux-project-menu--quiet .acpmux-menu-description{opacity:.75} .acpmux-project-menu .acpmux-project-search-field::placeholder{color:var(--acpmux-composer-dim)} .acpmux-project-empty{padding:6px 8px;color:var(--acpmux-composer-dim)} .acpmux-project-browse{display:block;width:100%;margin-top:4px;padding:7px 8px;border:0;border-top:.5px solid var(--acpmux-composer-edge);background:none;color:var(--agent-text);font:inherit;text-align:left;cursor:pointer} @@ -838,9 +850,4 @@ fieldset.acpmux-question[hidden]{display:none} .acpmux-trust-ask[data-asking]{color:var(--agent-text);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--agent-accent) 45%,transparent)}.acpmux-trust-ask-action.acpmux-trust-ask-primary{background:color-mix(in srgb,var(--agent-accent) 14%,transparent);font-weight:500}.acpmux-trust-ask-action.acpmux-trust-ask-primary:hover{background:color-mix(in srgb,var(--agent-accent) 22%,transparent)} .acpmux-trust-ask[data-asking]{flex-wrap:wrap;row-gap:6px;padding-top:7px;padding-bottom:7px}.acpmux-trust-ask-body{flex:1 1 240px;min-width:0;display:flex;flex-direction:column;gap:2px}.acpmux-trust-ask-body .acpmux-trust-ask-text{white-space:normal;overflow-wrap:anywhere;font-weight:500}.acpmux-trust-ask-detail{color:var(--acpmux-composer-dim);font-size:var(--text-detail);line-height:1.35;overflow-wrap:anywhere;letter-spacing:var(--text-detail--letter-spacing)}.acpmux-trust-ask-actions{display:flex;flex-wrap:wrap;gap:4px;margin-left:auto} -/* Search fields and their rows share the menu row metrics. Keep this override at the end so - legacy palettes that still emit the old fallback sizes resolve to the shared tokens. */ -.acpmux-file-search input { height: var(--ui-row-height); padding-inline: var(--ui-row-padding-inline); } -.acpmux-file-result { min-height: var(--ui-row-height); padding-inline: var(--ui-row-padding-inline); } -.acpmux-project-menu .acpmux-project-search { height: var(--ui-row-height); margin-block-end: 0; padding-inline: var(--ui-row-padding-inline); box-sizing: border-box; } -.acpmux-project-menu .acpmux-project-search-field { height: var(--ui-row-height); margin-block-end: 0; padding-inline: var(--acpmux-project-menu-edge); } +.acpmux-project-folders { max-height: min(45vh, 300px); overflow: hidden; } diff --git a/webviews/src/ui/Combobox.tsx b/webviews/src/ui/Combobox.tsx index 0e58347de090..7f9af64d3207 100644 --- a/webviews/src/ui/Combobox.tsx +++ b/webviews/src/ui/Combobox.tsx @@ -2,11 +2,12 @@ // caller computes the suggestions (they may arrive asynchronously) from `onQuery`. Return submits // the highlighted suggestion or the typed text, Tab completes, Escape cancels; Base UI owns the // combobox role, aria-activedescendant and the arrows. -import { useCallback, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { Autocomplete } from "@base-ui/react/autocomplete"; import { usePopupHost, usePortalContainer } from "./UiProvider"; import { useEscapingPopup } from "./popupHost"; import { cx } from "./cx"; +import { ScrollArea } from "./ScrollArea"; type BaseUIKeyEvent = KeyboardEvent & { preventBaseUIHandler?: () => void }; @@ -14,6 +15,8 @@ export interface ComboboxProps { /** The field's text when it opens. */ defaultValue?: string; suggestions: readonly string[]; + /** Leading actions remain below the field while the remaining suggestions scroll. */ + pinnedSuggestions?: readonly string[]; /** The text changed (typing, completion): compute new suggestions. */ onQuery(value: string): void; /** Return: the highlighted suggestion, else the text. */ @@ -61,6 +64,7 @@ export interface ComboboxProps { export function Combobox({ defaultValue = "", suggestions, + pinnedSuggestions, onQuery, onSubmit, onCancel, @@ -114,6 +118,27 @@ export function Combobox({ const completionRef = useRef<{ text: string; key: string } | undefined>(undefined); const pressActivatedRef = useRef(undefined); const listRef = useRef(null); + const pressingList = useRef(false); + useEffect(() => { + // Native scrollbar presses may blur the input with no relatedTarget. Treat that gesture as + // inside the list, just like an option press; a real outside press or Tab still closes it. + const press = (event: PointerEvent) => { + pressingList.current = !!listRef.current?.contains(event.target as Node); + }; + const end = () => { + pressingList.current = false; + }; + document.addEventListener("pointerdown", press, true); + document.addEventListener("pointerup", end, true); + document.addEventListener("pointercancel", end, true); + document.addEventListener("keydown", end, true); + return () => { + document.removeEventListener("pointerdown", press, true); + document.removeEventListener("pointerup", end, true); + document.removeEventListener("pointercancel", end, true); + document.removeEventListener("keydown", end, true); + }; + }, []); const keep = inline && keepHeight; const [tallest, setTallest] = useState(0); useLayoutEffect(() => { @@ -129,8 +154,13 @@ export function Combobox({ return () => window.removeEventListener("resize", reset); }, [keep]); const held = keep && tallest > 0; - const enabledSuggestions = suggestions.filter((item) => !isItemDisabled?.(item)); - const disabledSuggestions = suggestions.filter((item) => isItemDisabled?.(item)); + const hasPinned = Boolean(pinnedSuggestions?.length); + const enabledSuggestions = suggestions.filter( + (item) => !isItemDisabled?.(item) && (!pinnedSuggestions?.includes(item) || !value.trim()), + ); + const disabledSuggestions = suggestions.filter( + (item) => isItemDisabled?.(item) && !pinnedSuggestions?.includes(item), + ); const update = (next: string) => { setValue(next); onQuery(next); @@ -165,7 +195,6 @@ export function Combobox({ const highlighted = highlightedRef.current; const next = highlighted && enabledSuggestions.includes(highlighted) ? highlighted : enabledSuggestions[0]!; const completed = completionText?.(next) ?? next; - // A completed field is a normal Tab stop: keyboard users can reach actions beside the list. if (typed === completed) return; event.preventDefault(); event.preventBaseUIHandler?.(); @@ -174,53 +203,66 @@ export function Combobox({ } }; const renderItemContent = (item: string) => (renderItem ? renderItem(item) : item); + const renderOption = (item: string, index = enabledSuggestions.indexOf(item)) => ( + :
  • } + onPointerDownCapture={() => { + // A new gesture cannot inherit a press whose click was cancelled outside. + pressActivatedRef.current = undefined; + }} + onMouseDown={(event) => { + if (event.button !== 0) { + pressActivatedRef.current = undefined; + return; + } + if (isItemDisabled?.(item)) return; + event.preventDefault(); + pressActivatedRef.current = item; + onSubmit(item); + }} + onClick={(event) => { + // A real mouse click follows the primary press and must not submit twice. Keyboard and + // synthetic detail=0 clicks remain independent even if a press was cancelled outside. + const pressed = pressActivatedRef.current; + pressActivatedRef.current = undefined; + if (pressed === item && event.detail > 0) return; + // Base UI cancels pointerdown to retain input focus. Browsers then omit the + // compatibility mousedown, so a primary click must also activate on its own. + if (event.button === 0 && !isItemDisabled?.(item)) onSubmit(item); + }} + > + {renderItemContent(item)} + + ); const list = ( } + className={cx("ui-combobox-list", hasPinned ? "ui-combobox-pinned-list" : listClassName)} + render={hasPinned ?
    :
      } hidden={suggestions.length === 0 && !held} aria-hidden={held && suggestions.length === 0 ? true : undefined} style={held ? { minHeight: tallest, boxSizing: "border-box" } : undefined} > - - {(item: string) => ( - } - onPointerDownCapture={() => { - // A new gesture cannot inherit a press whose click was cancelled outside. - pressActivatedRef.current = undefined; - }} - onMouseDown={(event) => { - if (event.button !== 0) { - pressActivatedRef.current = undefined; - return; - } - if (isItemDisabled?.(item)) return; - event.preventDefault(); - pressActivatedRef.current = item; - onSubmit(item); - }} - onClick={(event) => { - // A real mouse click follows the primary press and must not submit twice. Keyboard and - // synthetic detail=0 clicks remain independent even if a press was cancelled outside. - const pressed = pressActivatedRef.current; - pressActivatedRef.current = undefined; - if (pressed === item && event.detail > 0) return; - // Base UI cancels pointerdown to retain input focus. Browsers then omit the - // compatibility mousedown, so a primary click must also activate on its own. - if (event.button === 0 && !isItemDisabled?.(item)) onSubmit(item); - }} - > - {renderItemContent(item)} - - )} - + {hasPinned && ( +
      + {pinnedSuggestions!.map((item) => renderOption(item, enabledSuggestions.indexOf(item)))} +
      + )} + {hasPinned ? ( + + + {(item) => (pinnedSuggestions!.includes(item) ? null : renderOption(item))} + + + ) : ( + {renderOption} + )} {disabledSuggestions.map((item) => (
    • { - if (cancelOnBlur) onCancel(); + onBlur={(event) => { + if (cancelOnBlur && !pressingList.current && !listRef.current?.contains(event.relatedTarget as Node | null)) + onCancel(); }} /> {inline ? ( diff --git a/webviews/src/ui/popupSurface.css b/webviews/src/ui/popupSurface.css index 223f8c64f420..e58d6e72096f 100644 --- a/webviews/src/ui/popupSurface.css +++ b/webviews/src/ui/popupSurface.css @@ -259,3 +259,11 @@ :where(.ui-submenu-chevron:dir(rtl)) { transform: rotate(-45deg); } + +/* Pinned combobox actions live outside the scrolling results. This sheet is loaded by the agent + pane as well as ui.css hosts, so the buttons use the same compact menu shape in both. */ +.ui-combobox-pinned-list { display: flex; flex-direction: column; min-height: 0; } +.ui-combobox-pinned { flex: none; margin-bottom: 4px; padding-bottom: 4px; } +.ui-combobox-scroll { min-height: 0; } +.ui-combobox-pinned > button { display: flex; align-items: center; width: 100%; border: 0; background: none; color: inherit; font: inherit; text-align: left; } +.ui-combobox-pinned > button:hover, .ui-combobox-pinned > button:focus-visible { background: var(--ui-row); } diff --git a/webviews/test/chat-new-worktree.test.tsx b/webviews/test/chat-new-worktree.test.tsx index 050134dbe0b0..dbea0d251351 100644 --- a/webviews/test/chat-new-worktree.test.tsx +++ b/webviews/test/chat-new-worktree.test.tsx @@ -73,10 +73,10 @@ const openFolderMenu = async () => { }; const menuItem = (name: string) => - // The folder popover's action rows (FolderPopover.tsx) follow its folders. - [...dom.window.document.querySelectorAll("[role=menuitem], [role=option]")].find((item) => - item.textContent?.includes(name), - ); + // Project actions are pinned under search, before the scrolling folders. + [ + ...dom.window.document.querySelectorAll("[role=menuitem], [role=option], .ui-combobox-pinned button"), + ].find((item) => item.textContent?.includes(name)); test("a new chat's folder menu offers New Worktree for its folder", async () => { const asked: string[] = []; diff --git a/webviews/test/composer-dropdown-containment.test.ts b/webviews/test/composer-dropdown-containment.test.ts new file mode 100644 index 000000000000..7150d9ad6978 --- /dev/null +++ b/webviews/test/composer-dropdown-containment.test.ts @@ -0,0 +1,176 @@ +import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, type Browser, type Locator, type Page } from "playwright"; +import { galleryFrame, warmGalleryFrame } from "./support/galleryFrame"; +import { requireBrowserLane } from "./support/requireBrowserLane"; + +await requireBrowserLane("composer-dropdown-containment.test.ts", async () => { + setDefaultTimeout(120_000); + const { createServer } = await import("vite"); + let server: Awaited>; + let browser: Browser; + beforeAll(async () => { + server = await createServer({ + configFile: path.resolve(import.meta.dir, "../vite.config.gallery-dev.ts"), + server: { host: "127.0.0.1", port: 0, strictPort: false }, + logLevel: "error", + }); + await server.listen(); + browser = await chromium.launch(); + await warmGalleryFrame(server, "agent-pane.composer-context", { browser, params: { variant: "folder-menu" } }); + await warmGalleryFrame(server, "agent-pane.project-chooser", { browser, params: { variant: "downward-3" } }); + }); + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + async function open(entry: string, variant: string, height: number) { + const page = await browser.newPage({ viewport: { width: 760, height } }); + const port = (server.httpServer!.address() as { port: number }).port; + await page.goto(`http://127.0.0.1:${port}${galleryFrame(entry, { variant, width: 760, height })}`); + await page.waitForFunction(() => document.documentElement.dataset.galleryReady === "1"); + expect(await page.locator("html").getAttribute("data-gallery-variant")).toBe(variant); + return page; + } + + async function contained(page: Page, popup: Locator) { + const bounds = await popup.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.y).toBeGreaterThanOrEqual(0); + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(page.viewportSize()!.height + 1); + } + + async function viewportContained(popup: Locator, viewport: Locator) { + const outer = (await popup.boundingBox())!; + const area = (await viewport.boundingBox())!; + expect(area.y).toBeGreaterThanOrEqual(outer.y - 1); + expect(area.y + area.height).toBeLessThanOrEqual(outer.y + outer.height + 2); + } + + async function reachable(row: Locator, viewport: Locator, popup: Locator, checkViewport = true) { + if (checkViewport) await viewportContained(popup, viewport); + await row.scrollIntoViewIfNeeded(); + const item = (await row.boundingBox())!; + const area = (await viewport.boundingBox())!; + const outer = (await popup.boundingBox())!; + if (checkViewport) { + expect(item.y).toBeGreaterThanOrEqual(area.y - 1); + expect(item.y + item.height).toBeLessThanOrEqual(area.y + area.height + 1); + } + expect(item.y).toBeGreaterThanOrEqual(outer.y - 1); + expect(item.y + item.height).toBeLessThanOrEqual(outer.y + outer.height + 1); + } + + for (const height of [180, 560]) { + test(`folder menu opens inside a ${height}px window and its last project is reachable`, async () => { + const page = await open("agent-pane.composer-context", "folder-menu", height); + try { + const popup = page.locator(".acpmux-folder-popover"); + await contained(page, popup); + const trigger = page.getByRole("button", { name: "Folder", exact: true }); + const triggerBounds = (await trigger.boundingBox())!; + const popupBounds = (await popup.boundingBox())!; + if (height >= 560) { + expect(popupBounds.y).toBeGreaterThanOrEqual(triggerBounds.y + triggerBounds.height - 1); + } + const projects = popup.locator('[role="option"]:has(.acpmux-location-row)'); + expect(await projects.count()).toBe(3); + const folderViewport = popup.locator(".ui-combobox-viewport"); + const folderMetrics = await folderViewport.evaluate((node) => ({ + client: node.clientHeight, + scroll: node.scrollHeight, + })); + expect(folderMetrics.scroll).toBeGreaterThanOrEqual(folderMetrics.client); + await contained(page, popup); + expect(await page.getByRole("button", { name: "Folder", exact: true }).getAttribute("aria-expanded")).toBe( + "true", + ); + } finally { + await page.close(); + } + }); + + test(`environment menu retains gpu-box inside a ${height}px window`, async () => { + const page = await open("agent-pane.composer-context", "environment-picker", height); + try { + const popup = page.locator(".acpmux-env-popover"); + await contained(page, popup); + const trigger = page.getByRole("button", { name: /^Environment/ }); + const triggerBounds = (await trigger.boundingBox())!; + const popupBounds = (await popup.boundingBox())!; + if (height >= 560) { + expect(popupBounds.y).toBeGreaterThanOrEqual(triggerBounds.y + triggerBounds.height - 1); + } + const gpu = popup.locator('[role="option"]').filter({ hasText: "gpu-box" }); + expect(await gpu.count()).toBe(1); + expect(await gpu.locator('[data-connected="true"]').count()).toBe(1); + expect(await gpu.locator("kbd").innerText()).toBe("⌥⌘2"); + const envMetrics = await popup + .locator(".acpmux-location-folders") + .evaluate((node) => ({ client: node.clientHeight, scroll: node.scrollHeight })); + expect(envMetrics.scroll).toBeGreaterThanOrEqual(envMetrics.client); + await contained(page, popup); + } finally { + await page.close(); + } + }); + } + + for (const [variant, triggerName, popupSelector, height, top] of [ + ["environment-picker", /^Environment/, ".acpmux-env-popover", 760, "740px"], + ] as const) { + test(`${variant} flips above when the trigger is at the bottom edge`, async () => { + const page = await open("agent-pane.composer-context", variant, height); + try { + await page.keyboard.press("Escape"); + const context = page.locator(".acpmux-composer-context"); + await context.evaluate((node) => { + const element = node as HTMLElement; + element.style.position = "fixed"; + element.style.left = "8px"; + element.style.top = top; + element.style.bottom = "auto"; + }); + const trigger = + typeof triggerName === "string" + ? page.getByRole("button", { name: triggerName, exact: true }) + : page.getByRole("button", { name: triggerName }); + await trigger.locator("..").evaluate((node) => { + const rect = node.getBoundingClientRect(); + Object.defineProperty(node, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(rect.x, 700, rect.width, rect.height), + }); + }); + await trigger.dispatchEvent("pointerdown", { button: 0 }); + await trigger.dispatchEvent("click", { detail: 1 }); + const popup = page.locator(popupSelector); + await popup.waitFor(); + expect(await popup.getAttribute("data-side")).toBe("top"); + await contained(page, popup); + } finally { + await page.close(); + } + }); + } + + for (const count of [3, 8, 15]) { + test(`project gallery renders all ${count} projects with contained scrolling`, async () => { + const page = await open("agent-pane.project-chooser", `downward-${count}`, 300); + try { + const popup = page.locator(".acpmux-project-menu"); + await contained(page, popup); + expect(await popup.locator("[data-path]").count()).toBe(count); + const actions = popup.locator(".ui-combobox-pinned"); + const before = await actions.boundingBox(); + const last = popup.locator('[role="option"]').filter({ has: page.locator(`[data-path$="/project-${count}"]`) }); + await reachable(last, popup.locator(".ui-combobox-viewport"), popup, false); + expect(await actions.boundingBox()).toEqual(before); + await contained(page, popup); + } finally { + await page.close(); + } + }); + } +}); diff --git a/webviews/test/composer-location-placement.test.ts b/webviews/test/composer-location-placement.test.ts new file mode 100644 index 000000000000..c8e0a2296584 --- /dev/null +++ b/webviews/test/composer-location-placement.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { resolveUiSide } from "../src/ui/anchor"; + +const anchor = (top: number, bottom = top + 28) => ({ top, bottom }); + +test("project and environment menus prefer below when the full menu fits", () => { + const side = resolveUiSide(anchor(120), 320, 720, { side: "below" }); + expect(side).toBe("below"); +}); + +test("the shared placement rule flips above only when below has less room", () => { + expect(resolveUiSide(anchor(620), 320, 720, { side: "below" })).toBe("above"); + expect(resolveUiSide(anchor(380), 320, 420, { side: "below" })).toBe("above"); +}); + +test("a short pane still uses the side with usable room", () => { + expect(resolveUiSide(anchor(180), 320, 220, { side: "below" })).toBe("above"); +}); diff --git a/webviews/test/composer-location-strip.test.ts b/webviews/test/composer-location-strip.test.ts index 054f6bb5381f..34fe71592cbd 100644 --- a/webviews/test/composer-location-strip.test.ts +++ b/webviews/test/composer-location-strip.test.ts @@ -539,4 +539,60 @@ await requireBrowserLane("composer-location-strip.test.ts", async () => { expect(after.edge).toBe(before.edge); expect(after.rect).toEqual(before.rect); }); + test("long environment menu scrolls to its last machine", async () => { + const page = await open("agent-pane.composer-context", "environment-picker-fleet", 720); + const list = page.locator(".acpmux-env-popover .acpmux-location-folders"); + const before = await list.evaluate((node) => ({ height: node.clientHeight, total: node.scrollHeight })); + expect(before.total).toBeGreaterThan(before.height); + await list.hover(); + await page.mouse.wheel(0, 3000); + await page.waitForFunction(() => { + const node = document.querySelector(".acpmux-env-popover .acpmux-location-folders"); + return Boolean(node && node.scrollTop > 0); + }); + expect(await list.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + const last = list.locator("[role=option]").last(); + expect(await last.isVisible()).toBe(true); + expect( + await last.evaluate((node) => { + const list = node.closest(".acpmux-location-folders")!.getBoundingClientRect(); + const row = node.getBoundingClientRect(); + return row.bottom <= list.bottom && row.top >= list.top; + }), + ).toBe(true); + await page.close(); + }); + + test("Escape from a pinned project action closes and restores trigger focus", async () => { + const page = await open("agent-pane.composer-context", "folder-long-list", 720); + const popup = page.locator(".acpmux-folder-popover"); + await popup.locator(".ui-combobox-pinned button").first().focus(); + await page.keyboard.press("Escape"); + await popup.waitFor({ state: "detached" }); + expect(await page.evaluate(() => document.activeElement?.getAttribute("aria-label"))).toBe("Folder"); + await page.close(); + }); + + test("project actions stay pinned while the inside thumb scrolls without dismissing", async () => { + const page = await open("agent-pane.composer-context", "folder-long-list", 720); + const popup = page.locator(".acpmux-folder-popover"); + const actions = popup.locator(".ui-combobox-pinned"); + const action = actions.locator("button").first(); + expect(await action.getAttribute("data-press-pick")).not.toBeNull(); + expect(await action.evaluate((node) => getComputedStyle(node).borderTopWidth)).toBe("0px"); + const thumb = popup.locator(".cmux-scrollbar-thumb").first(); + await thumb.waitFor({ state: "visible" }); + const before = await actions.boundingBox(); + const bounds = await popup.boundingBox(); + const grip = await thumb.boundingBox(); + expect(grip!.x + grip!.width).toBeLessThanOrEqual(bounds!.x + bounds!.width); + await page.mouse.move(grip!.x + grip!.width / 2, grip!.y + grip!.height / 2); + await page.mouse.down(); + await page.mouse.move(grip!.x + grip!.width / 2, grip!.y + grip!.height / 2 + 80, { steps: 5 }); + await page.mouse.up(); + expect(await popup.count()).toBe(1); + expect(await actions.boundingBox()).toEqual(before); + expect(await popup.locator(".ui-combobox-viewport").evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + await page.close(); + }); }); diff --git a/webviews/test/composer-searchable-pickers.test.tsx b/webviews/test/composer-searchable-pickers.test.tsx index 94e36ebdcd02..89004f2a7b69 100644 --- a/webviews/test/composer-searchable-pickers.test.tsx +++ b/webviews/test/composer-searchable-pickers.test.tsx @@ -355,7 +355,7 @@ test("the project list announces the committed project and private-folder select ), ); await click(host.querySelector("button")!); - const selected = document.querySelector('[role="option"][aria-current="true"]'); + const selected = document.querySelector('[aria-current="true"]'); expect(selected).toBeTruthy(); expect(selected!.textContent).toContain(privateFolder ? "Do not work in a project" : "current"); await key(document.querySelector('[role="combobox"]')!, "Escape"); @@ -413,8 +413,108 @@ test("an unmatched proposed project path is never checked as the private-folder ); await click(host.querySelector("button")!); await type(document.querySelector('[role="combobox"]')!, "/tmp/new-project"); - const proposed = document.querySelector('[role="option"]')!; - expect(proposed.textContent).toContain("/tmp/new-project"); - expect(proposed.textContent).not.toContain("✓"); - expect(proposed.getAttribute("aria-current")).toBeNull(); + const proposed = [...document.querySelectorAll('[role="option"]')].find((row) => + row.textContent?.includes("/tmp/new-project"), + ); + expect(proposed).toBeTruthy(); + expect(proposed!.textContent).not.toContain("✓"); + expect(proposed!.getAttribute("aria-current")).toBeNull(); +}); + +test("pinned picker actions precede scrolling suggestions and remain keyboard selectable", async () => { + const { createRoot } = await import("react-dom/client"); + const { Combobox } = await import("../src/ui/Combobox"); + const calls: string[] = []; + root = createRoot(host); + await act(async () => + root!.render( + {}} + onSubmit={(value) => calls.push(value)} + onCancel={() => {}} + label="Projects" + inline + />, + ), + ); + const pinned = host.querySelector(".ui-combobox-pinned")!; + const scroll = host.querySelector(".ui-combobox-scroll")!; + expect([...pinned.querySelectorAll(".ui-combobox-item")].map((node) => node.textContent)).toEqual([ + "No project", + "Add project", + ]); + expect([...scroll.querySelectorAll('[role="option"]')].map((node) => node.textContent)).toEqual(["Atlas", "Relay"]); + const field = host.querySelector("input")!; + await act(async () => field.focus()); + await key(field, "ArrowDown"); + await key(field, "Enter"); + await key(field, "ArrowDown"); + await key(field, "ArrowDown"); + await key(field, "Enter"); + await key(field, "ArrowUp"); + await key(field, "ArrowUp"); + await key(field, "Enter"); + expect(calls).toEqual(["No project", "Atlas", "No project"]); + await type(field, "Atlas"); + await key(field, "ArrowDown"); + await key(field, "Enter"); + expect(calls).toEqual(["No project", "Atlas", "No project", "Atlas"]); +}); + +test("an empty pinned action list does not add an orphan divider", async () => { + const { createRoot } = await import("react-dom/client"); + const { Combobox } = await import("../src/ui/Combobox"); + root = createRoot(host); + await act(async () => + root!.render( + {}} + onSubmit={() => {}} + onCancel={() => {}} + label="Projects" + inline + />, + ), + ); + expect(host.querySelector(".ui-combobox-pinned")).toBeNull(); +}); + +test("scrollbar blur keeps a searchable menu open while outside blur cancels", async () => { + const { createRoot } = await import("react-dom/client"); + const { Combobox } = await import("../src/ui/Combobox"); + let cancelled = 0; + root = createRoot(host); + await act(async () => + root!.render( + {}} + onSubmit={() => {}} + onCancel={() => { + cancelled += 1; + }} + label="Projects" + inline + />, + ), + ); + const field = host.querySelector("input")!; + const list = host.querySelector('[role="listbox"]')!; + await act(async () => { + field.focus(); + list.dispatchEvent(new dom.window.MouseEvent("pointerdown", { bubbles: true })); + field.blur(); + }); + expect(cancelled).toBe(0); + await act(async () => { + document.dispatchEvent(new dom.window.MouseEvent("pointerup", { bubbles: true })); + field.focus(); + document.body.dispatchEvent(new dom.window.MouseEvent("pointerdown", { bubbles: true })); + field.blur(); + }); + expect(cancelled).toBe(1); }); diff --git a/webviews/test/new-tab-no-entry-classic.test.ts b/webviews/test/new-tab-no-entry-classic.test.ts index 85c0ec6c2a27..d4abf8e810c4 100644 --- a/webviews/test/new-tab-no-entry-classic.test.ts +++ b/webviews/test/new-tab-no-entry-classic.test.ts @@ -35,6 +35,22 @@ await requireBrowserLane("new-tab-no-entry-classic.test.ts", async () => { await vite?.close(); }); + test("project results alone scroll; the popover never adds a second scrollbar", async () => { + const page = await browser!.newPage({ viewport: { width: 900, height: 700 } }); + try { + const port = (vite!.httpServer!.address() as { port: number }).port; + await page.goto(`http://127.0.0.1:${port}${galleryFrame(ENTRY, { variant: "idle" })}`); + await page.waitForFunction(() => document.documentElement.dataset.galleryReady, undefined, { timeout: 60_000 }); + await page.click(".acpmux-project-button"); + const popup = page.locator(".acpmux-project-menu"); + await popup.waitFor(); + expect(await popup.evaluate((node) => getComputedStyle(node).overflowY)).toBe("visible"); + expect(await popup.locator(".ui-combobox-viewport").count()).toBe(1); + } finally { + await page.close(); + } + }); + test("the classic New Tab's project chooser opens at once", async () => { const page = await browser!.newPage({ viewport: { width: 900, height: 700 } }); try { diff --git a/webviews/test/project-chooser-empty.test.tsx b/webviews/test/project-chooser-empty.test.tsx index 947b875445b7..4672d65e78a7 100644 --- a/webviews/test/project-chooser-empty.test.tsx +++ b/webviews/test/project-chooser-empty.test.tsx @@ -62,7 +62,16 @@ test("the project picker with no projects says none are recent until a search fi const { ProjectChooser } = await import("../src/agent-session/acpmux/ProjectChooser"); root = createRoot(host); await act(async () => - root!.render( {}} onBrowse={() => {}} side="bottom" />), + root!.render( + {}} + onBrowse={() => {}} + onNoProject={() => {}} + side="bottom" + />, + ), ); await act(async () => host.querySelector("button")!.click()); const empty = () => document.querySelector(".acpmux-project-empty")?.textContent;