diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 20e66263892b9cbb10151a494521d913a42db664..e9bd7c99e045fd2178ae5f4fb2583e0ab42a6b38 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -3,13 +3,12 @@ import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDr import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; -import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react"; +import { FolderPlusIcon } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; import { shortcutLabelForCommand } from "~/keybindings"; -import { projectIconColorClassName } from "~/projectIconColors"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useScratchProject } from "~/hooks/useScratchProject"; @@ -30,20 +29,27 @@ import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; import { ProjectFavicon } from "../ProjectFavicon"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { - Menu, - MenuItem, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator, - MenuTrigger, -} from "../ui/menu"; + Combobox, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxSearchInput, + ComboboxTrigger, + useComboboxFilter, +} from "../ui/combobox"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { InlineButton } from "../ui/button"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -// Menu value for "No project"; real entries are keyed by logical project key. -const NO_PROJECT_VALUE = "no-project"; +// Picker value for the "Add project" row; real entries are keyed by logical +// project key. +const ADD_PROJECT_VALUE = "add-project"; + +interface PickerItem { + readonly value: string; + readonly label: string; +} interface DraftHeroHeadlineProps { readonly draftId: DraftId | null; @@ -147,11 +153,15 @@ export function DraftHeroHeadline({ const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; - // The project that hosts threads without a project appears once, as the - // "No project" item, not as a project row. - const menuEntries = projectPickerEntries.filter( - ({ targetProject }) => - !isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)), + // The project that hosts threads without a project is not a row: the line + // under the headline is the way into it. + const menuEntries = useMemo( + () => + projectPickerEntries.filter( + ({ targetProject }) => + !isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)), + ), + [projectPickerEntries, scratchWorkspaceRootFor], ); const activeProject = activeProjectRef === null @@ -168,6 +178,30 @@ export function DraftHeroHeadline({ const isScratchDraft = activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRoot); + // {value, label} items let Base UI drive the combobox selection while the + // popup search filters the same collection. "Add project" is an action, not + // a project, so it only trails the unfiltered list. + const pickerItems = useMemo( + () => [ + ...menuEntries.map(({ group }) => ({ value: group.projectKey, label: group.displayName })), + { value: ADD_PROJECT_VALUE, label: "Add project" }, + ], + [menuEntries], + ); + const [pickerOpen, setPickerOpen] = useState(false); + const [pickerQuery, setPickerQuery] = useState(""); + const pickerFilter = useComboboxFilter(); + const filteredPickerItems = useMemo(() => { + const query = pickerQuery.trim(); + if (query.length === 0) return pickerItems; + return pickerItems.filter( + (item) => + item.value !== ADD_PROJECT_VALUE && + pickerFilter.contains(item, query, (candidate) => candidate.label), + ); + }, [pickerFilter, pickerItems, pickerQuery]); + const selectedPickerItem = pickerItems.find((item) => item.value === activeProjectKey) ?? null; + // The picker can change the draft's target while the no-project home is // still being opened; a stale continuation must not retarget it again. const latestTargetRef = useRef({ draftId, activeProjectKey, scratchTargetEnvironmentId }); @@ -234,7 +268,32 @@ export function DraftHeroHeadline({ }; const projectSelector = shouldShowProjectMenu ? ( - + item.label} + isItemEqualToValue={(a, b) => a.value === b.value} + open={pickerOpen} + onOpenChange={(open) => { + setPickerOpen(open); + setPickerQuery(""); + }} + value={selectedPickerItem} + onValueChange={(item) => { + if (!item) return; + setPickerOpen(false); + if (item.value === ADD_PROJECT_VALUE) { + openAddProject(); + return; + } + const entry = projectEntryByKey.get(item.value); + if (!entry || item.value === activeProjectKey) { + return; + } + selectProject(entry.targetProject, entry.group.projectKey); + }} + > } data-draft-project-trigger="" className="pointer-events-auto max-w-64 align-baseline" @@ -257,65 +316,43 @@ export function DraftHeroHeadline({ {activeProjectDisplayName} ) : null} - - { - if (value === NO_PROJECT_VALUE) { - void startScratch(); - return; - } - const entry = projectEntryByKey.get(value as string); - if (!entry || value === activeProjectKey) { - return; - } - selectProject(entry.targetProject, entry.group.projectKey); - }} - > - {scratchWorkspaceRoot === null ? null : ( - - - {/* Boxed like ProjectFavicon so the label lines up with project rows. */} - - No project - - - )} - {menuEntries.map(({ group }) => { + + setPickerQuery(event.target.value)} + /> + No matching projects. + + {(item: PickerItem) => { + const entry = projectEntryByKey.get(item.value); return ( - - - - - }> - {group.displayName} - - {group.displayName} - - {showProjectEnvironments ? ( - - ) : null} - - + + {item.value === ADD_PROJECT_VALUE ? ( + + ) : entry ? ( + + ) : null} + + }> + {item.label} + + {item.label} + + {entry && showProjectEnvironments ? ( + + ) : null} + ); - })} - - {projectPickerEntries.length > 0 ? : null} - - - Add project - - - + }} + + + ) : (