From 7715ef38d79068e48a7c2d6387f801492127cdee Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 02:36:21 -0700 Subject: [PATCH] Add search to new-thread project picker --- .../src/components/chat/DraftHeroHeadline.tsx | 176 +++++++++++++----- .../chat/draftHeroProjectSearch.test.ts | 31 +++ .../components/chat/draftHeroProjectSearch.ts | 20 ++ 3 files changed, 181 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/components/chat/draftHeroProjectSearch.test.ts create mode 100644 apps/web/src/components/chat/draftHeroProjectSearch.ts diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 98091f9aab6..4796edfd09c 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,7 +1,10 @@ import type { ScopedProjectRef } from "@t3tools/contracts"; -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { FolderPlusIcon } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { + scopedProjectKey, + scopeProjectRef, +} from "@t3tools/client-runtime/environment"; +import { FolderPlusIcon, SearchIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; @@ -15,14 +18,15 @@ import { useProjects, useThreadShells } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { - Menu, - MenuItem, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator, - MenuTrigger, -} from "../ui/menu"; + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, +} from "../ui/combobox"; +import { filterDraftHeroProjects } from "./draftHeroProjectSearch"; interface DraftHeroHeadlineProps { readonly activeProjectRef: ScopedProjectRef | null; @@ -37,15 +41,27 @@ export function DraftHeroHeadline({ const threads = useThreadShells(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); + const projectGroupingSettings = useClientSettings( + selectProjectGroupingSettings, + ); + const projectSortOrder = useClientSettings( + (settings) => settings.sidebarProjectSortOrder, + ); const handleNewThread = useNewThreadHandler(); - const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); + const openAddProject = useCallback( + () => openCommandPalette({ open: "add-project" }), + [], + ); + const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false); + const [projectQuery, setProjectQuery] = useState(""); const environmentLabelById = useMemo( () => new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), + environments.map( + (environment) => + [environment.environmentId, environment.label] as const, + ), ), [environments], ); @@ -80,60 +96,128 @@ export function DraftHeroHeadline({ [activeProjectRef, projectGroups], ); const projectEntryByKey = useMemo( - () => new Map(projectPickerEntries.map((entry) => [entry.group.projectKey, entry] as const)), + () => + new Map( + projectPickerEntries.map( + (entry) => [entry.group.projectKey, entry] as const, + ), + ), [projectPickerEntries], ); + const filteredProjectEntries = useMemo( + () => + filterDraftHeroProjects( + projectPickerEntries.map((entry) => ({ + entry, + title: entry.group.displayName, + workspaceRoot: entry.targetProject.workspaceRoot, + })), + projectQuery, + ).map(({ entry }) => entry), + [projectPickerEntries, projectQuery], + ); const activeProjectGroup = activeProjectRef === null ? null : (projectGroups.find((group) => group.memberProjectRefs.some( - (projectRef) => scopedProjectKey(projectRef) === scopedProjectKey(activeProjectRef), + (projectRef) => + scopedProjectKey(projectRef) === + scopedProjectKey(activeProjectRef), ), ) ?? null); const activeProjectKey = activeProjectGroup?.projectKey ?? ""; - const activeProjectDisplayName = activeProjectGroup?.displayName ?? activeProjectTitle; + const activeProjectDisplayName = + activeProjectGroup?.displayName ?? activeProjectTitle; const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; const projectSelector = shouldShowProjectMenu ? ( - - group.projectKey)} + filteredItems={filteredProjectEntries.map( + ({ group }) => group.projectKey, + )} + open={isProjectPickerOpen} + value={activeProjectKey} + onOpenChange={(open) => { + setIsProjectPickerOpen(open); + if (!open) { + setProjectQuery(""); + } + }} + onValueChange={(value) => { + if (!value || value === activeProjectKey) { + setIsProjectPickerOpen(false); + return; + } + const entry = projectEntryByKey.get(value); + if (!entry) { + return; + } + setIsProjectPickerOpen(false); + const project = entry.targetProject; + void handleNewThread( + scopeProjectRef(project.environmentId, project.id), + { + replace: true, + }, + ); + }} + > + {activeProjectDisplayName ?? "Choose a project"} - - - { - const entry = projectEntryByKey.get(value as string); - if (!entry || value === activeProjectKey) { - return; - } - const project = entry.targetProject; - void handleNewThread(scopeProjectRef(project.environmentId, project.id), { - replace: true, - }); - }} - > - {projectPickerEntries.map(({ group }) => { + + +
+
+
+
+ No matching projects. + + {filteredProjectEntries.map(({ group }) => { return ( - + {group.displayName} - + ); })} -
- - - - New project - -
-
+ +
+ +
+ + ) : (