diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx
index 3f50d3818b5..448e8bc2334 100644
--- a/apps/web/src/components/chat/DraftHeroHeadline.tsx
+++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx
@@ -1,21 +1,28 @@
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 { FolderPlusIcon, SearchIcon } from "lucide-react";
+import { useCallback, useMemo, useRef, useState } from "react";
import { openCommandPalette } from "~/commandPaletteBus";
import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
import { useProjects, useThreadShells } from "~/state/entities";
import { sortScopedProjectsForSidebar } from "../Sidebar.logic";
import {
- Menu,
- MenuItem,
- MenuPopup,
- MenuRadioGroup,
- MenuRadioItem,
- MenuSeparator,
- MenuTrigger,
-} from "../ui/menu";
+ Combobox,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxListVirtualized,
+ ComboboxPopup,
+ ComboboxSeparator,
+ ComboboxTrigger,
+} from "../ui/combobox";
+import {
+ DRAFT_HERO_NEW_PROJECT_ITEM,
+ filterDraftHeroProjects,
+ getDraftHeroFilteredPickerItems,
+ getDraftHeroProjectPickerItemValue,
+ resolveDraftHeroPickerKeyAction,
+} from "./draftHeroProjectSearch";
interface DraftHeroHeadlineProps {
readonly activeProjectRef: ScopedProjectRef | null;
@@ -30,66 +37,173 @@ export function DraftHeroHeadline({
const threads = useThreadShells();
const handleNewThread = useNewThreadHandler();
const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
+ const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
+ const [projectQuery, setProjectQuery] = useState("");
+ const hasNewProjectKeyboardIntentRef = useRef(false);
const orderedProjects = useMemo(
() => sortScopedProjectsForSidebar(projects, threads, "updated_at"),
[projects, threads],
);
+ const filteredProjects = useMemo(
+ () => filterDraftHeroProjects(orderedProjects, projectQuery),
+ [orderedProjects, projectQuery],
+ );
+ const filteredProjectItems = useMemo(
+ () =>
+ getDraftHeroFilteredPickerItems(
+ filteredProjects.map((project) =>
+ getDraftHeroProjectPickerItemValue(
+ scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
+ ),
+ ),
+ ),
+ [filteredProjects],
+ );
const projectByKey = useMemo(
() =>
new Map(
orderedProjects.map(
(project) =>
[
- scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
+ getDraftHeroProjectPickerItemValue(
+ scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
+ ),
project,
] as const,
),
),
[orderedProjects],
);
- const activeProjectKey = activeProjectRef === null ? "" : scopedProjectKey(activeProjectRef);
+ const activeProjectKey =
+ activeProjectRef === null
+ ? ""
+ : getDraftHeroProjectPickerItemValue(scopedProjectKey(activeProjectRef));
const hasResolvedProject = activeProjectTitle !== null;
const canChooseProject = orderedProjects.length > 0;
const shouldShowProjectMenu = canChooseProject;
+ const hasFailedProjectSearch = projectQuery.trim().length > 0 && filteredProjects.length === 0;
const projectSelector = shouldShowProjectMenu ? (
-
-
+ getDraftHeroProjectPickerItemValue(
+ scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
+ ),
+ ),
+ DRAFT_HERO_NEW_PROJECT_ITEM,
+ ]}
+ filteredItems={filteredProjectItems}
+ open={isProjectPickerOpen}
+ value={activeProjectKey}
+ onOpenChange={(open) => {
+ setIsProjectPickerOpen(open);
+ if (!open) {
+ hasNewProjectKeyboardIntentRef.current = false;
+ setProjectQuery("");
+ }
+ }}
+ onValueChange={(value) => {
+ if (value === DRAFT_HERO_NEW_PROJECT_ITEM) {
+ setIsProjectPickerOpen(false);
+ openAddProject();
+ return;
+ }
+ if (!value || value === activeProjectKey) {
+ setIsProjectPickerOpen(false);
+ return;
+ }
+ const project = projectByKey.get(value);
+ if (!project) {
+ return;
+ }
+ setIsProjectPickerOpen(false);
+ void handleNewThread(scopeProjectRef(project.environmentId, project.id), {
+ replace: true,
+ });
+ }}
+ >
+
{activeProjectTitle ?? "Choose a project"}
-
-
- {
- const project = projectByKey.get(value as string);
- if (!project || value === activeProjectKey) {
- return;
- }
- void handleNewThread(scopeProjectRef(project.environmentId, project.id), {
- replace: true,
- });
- }}
- >
- {orderedProjects.map((project) => {
- const key = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));
- return (
-
- {project.title}
-
- );
- })}
-
-
-
-
- New project
-
-
-
+
+
+
+
+
+ {
+ hasNewProjectKeyboardIntentRef.current = false;
+ setProjectQuery(event.target.value);
+ }}
+ onKeyDownCapture={(event) => {
+ // A failed search leaves the footer as Base UI's only highlighted item.
+ // Require an arrow-key gesture before letting Enter commit that action.
+ const action = resolveDraftHeroPickerKeyAction({
+ key: event.key,
+ isComposing: event.nativeEvent.isComposing,
+ keyCode: event.nativeEvent.keyCode,
+ hasFailedProjectSearch,
+ hasNewProjectKeyboardIntent: hasNewProjectKeyboardIntentRef.current,
+ });
+ if (action === "preserve-composition") {
+ // Let the browser commit the IME text, but do not let Base UI
+ // interpret the same Enter as selecting the highlighted footer.
+ event.stopPropagation();
+ } else if (action === "enable-new-project") {
+ hasNewProjectKeyboardIntentRef.current = true;
+ } else if (action === "block-selection") {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }}
+ />
+
+
+ {filteredProjects.length === 0 ? (
+
+ No matching projects.
+
+ ) : null}
+
+
+ {filteredProjects.map((project) => {
+ const key = getDraftHeroProjectPickerItemValue(
+ scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
+ );
+ return (
+
+ {project.title}
+
+ );
+ })}
+
+
+
+
+
+ New project
+
+
+
+
+
) : (
{
+ it("narrows candidates as the title query becomes more specific", () => {
+ expect(filterDraftHeroProjects(projects, "m").map((project) => project.title)).toEqual([
+ "Mobile App",
+ "Marketing Site",
+ ]);
+ expect(filterDraftHeroProjects(projects, "mob").map((project) => project.title)).toEqual([
+ "Mobile App",
+ ]);
+ });
+
+ it("matches project paths and multiple query tokens", () => {
+ expect(
+ filterDraftHeroProjects(projects, "products market").map((project) => project.title),
+ ).toEqual(["Marketing Site"]);
+ });
+
+ it("preserves candidate order for an empty query", () => {
+ expect(filterDraftHeroProjects(projects, " ")).toEqual(projects);
+ });
+});
+
+describe("getDraftHeroFilteredPickerItems", () => {
+ it("keeps project values disjoint even when their scoped key matches the action value", () => {
+ const projectItem = getDraftHeroProjectPickerItemValue(DRAFT_HERO_NEW_PROJECT_ITEM);
+
+ expect(projectItem).not.toBe(DRAFT_HERO_NEW_PROJECT_ITEM);
+ expect(getDraftHeroFilteredPickerItems([projectItem])).toEqual([
+ projectItem,
+ DRAFT_HERO_NEW_PROJECT_ITEM,
+ ]);
+ });
+
+ it("keeps New project in the navigable collection after a failed search", () => {
+ expect(getDraftHeroFilteredPickerItems([])).toEqual([DRAFT_HERO_NEW_PROJECT_ITEM]);
+ });
+
+ it("keeps New project reachable when browsing or navigating successful search results", () => {
+ expect(getDraftHeroFilteredPickerItems(["project-1"])).toEqual([
+ "project-1",
+ DRAFT_HERO_NEW_PROJECT_ITEM,
+ ]);
+ });
+});
+
+describe("resolveDraftHeroPickerKeyAction", () => {
+ it("blocks an immediate Enter after a failed project search", () => {
+ expect(
+ resolveDraftHeroPickerKeyAction({
+ key: "Enter",
+ isComposing: false,
+ keyCode: 13,
+ hasFailedProjectSearch: true,
+ hasNewProjectKeyboardIntent: false,
+ }),
+ ).toBe("block-selection");
+ });
+
+ it("allows deliberate ArrowDown then Enter access to New project", () => {
+ const navigationAction = resolveDraftHeroPickerKeyAction({
+ key: "ArrowDown",
+ isComposing: false,
+ keyCode: 40,
+ hasFailedProjectSearch: true,
+ hasNewProjectKeyboardIntent: false,
+ });
+ expect(navigationAction).toBe("enable-new-project");
+
+ expect(
+ resolveDraftHeroPickerKeyAction({
+ key: "Enter",
+ isComposing: false,
+ keyCode: 13,
+ hasFailedProjectSearch: true,
+ hasNewProjectKeyboardIntent: navigationAction === "enable-new-project",
+ }),
+ ).toBe("allow");
+ });
+
+ it("allows Enter to commit an active IME composition", () => {
+ expect(
+ resolveDraftHeroPickerKeyAction({
+ key: "Enter",
+ isComposing: true,
+ keyCode: 13,
+ hasFailedProjectSearch: true,
+ hasNewProjectKeyboardIntent: false,
+ }),
+ ).toBe("preserve-composition");
+ });
+
+ it("allows the legacy 229 composition fallback", () => {
+ expect(
+ resolveDraftHeroPickerKeyAction({
+ key: "Enter",
+ isComposing: false,
+ keyCode: 229,
+ hasFailedProjectSearch: true,
+ hasNewProjectKeyboardIntent: false,
+ }),
+ ).toBe("preserve-composition");
+ });
+});
diff --git a/apps/web/src/components/chat/draftHeroProjectSearch.ts b/apps/web/src/components/chat/draftHeroProjectSearch.ts
new file mode 100644
index 00000000000..b197aaf8c51
--- /dev/null
+++ b/apps/web/src/components/chat/draftHeroProjectSearch.ts
@@ -0,0 +1,60 @@
+type SearchableProject = {
+ readonly title: string;
+ readonly workspaceRoot: string;
+};
+
+const DRAFT_HERO_PROJECT_ITEM_PREFIX = "draft-hero:project:";
+
+export const DRAFT_HERO_NEW_PROJECT_ITEM = "draft-hero:action:new-project";
+
+export function getDraftHeroProjectPickerItemValue(projectKey: string): string {
+ return `${DRAFT_HERO_PROJECT_ITEM_PREFIX}${projectKey}`;
+}
+
+export type DraftHeroPickerKeyAction =
+ | "allow"
+ | "block-selection"
+ | "enable-new-project"
+ | "preserve-composition";
+
+export function filterDraftHeroProjects(
+ projects: ReadonlyArray,
+ query: string,
+): T[] {
+ const tokens = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
+
+ if (tokens.length === 0) {
+ return [...projects];
+ }
+
+ return projects.filter((project) => {
+ const searchText = `${project.title}\n${project.workspaceRoot}`.toLocaleLowerCase();
+ return tokens.every((token) => searchText.includes(token));
+ });
+}
+
+export function getDraftHeroFilteredPickerItems(projectItems: ReadonlyArray): string[] {
+ return [...projectItems, DRAFT_HERO_NEW_PROJECT_ITEM];
+}
+
+export function resolveDraftHeroPickerKeyAction(input: {
+ readonly key: string;
+ readonly isComposing: boolean;
+ readonly keyCode: number;
+ readonly hasFailedProjectSearch: boolean;
+ readonly hasNewProjectKeyboardIntent: boolean;
+}): DraftHeroPickerKeyAction {
+ if (input.isComposing || input.keyCode === 229) {
+ return "preserve-composition";
+ }
+ if (!input.hasFailedProjectSearch) {
+ return "allow";
+ }
+ if (input.key === "ArrowDown" || input.key === "ArrowUp") {
+ return "enable-new-project";
+ }
+ if (input.key === "Enter" && !input.hasNewProjectKeyboardIntent) {
+ return "block-selection";
+ }
+ return "allow";
+}