Skip to content
202 changes: 158 additions & 44 deletions apps/web/src/components/chat/DraftHeroHeadline.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 ? (
<Menu>
<MenuTrigger
<Combobox
autoHighlight
filter={null}
items={[
...orderedProjects.map((project) =>
getDraftHeroProjectPickerItemValue(
scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
),
),
DRAFT_HERO_NEW_PROJECT_ITEM,
]}
Comment thread
cursor[bot] marked this conversation as resolved.
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,
});
}}
>
<ComboboxTrigger
aria-label={hasResolvedProject ? "Change project" : "Choose a project"}
className="pointer-events-auto inline cursor-pointer border-current border-b border-dotted text-foreground underline-offset-8 transition-opacity hover:opacity-75 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
>
{activeProjectTitle ?? "Choose a project"}
</MenuTrigger>
<MenuPopup align="center" className="max-h-80 w-64 overflow-y-auto">
<MenuRadioGroup
value={activeProjectKey}
onValueChange={(value) => {
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 (
<MenuRadioItem key={key} value={key} closeOnClick>
<span className="min-w-0 truncate">{project.title}</span>
</MenuRadioItem>
);
})}
</MenuRadioGroup>
<MenuSeparator />
<MenuItem onClick={openAddProject}>
<FolderPlusIcon />
New project
</MenuItem>
</MenuPopup>
</Menu>
</ComboboxTrigger>
<ComboboxPopup align="center" className="w-72 max-w-[calc(100vw-1rem)]">
<div className="shrink-0 px-3 pt-2.5">
<div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute top-1.5 left-0 size-4 text-muted-foreground/55"
/>
<ComboboxInput
autoFocus
className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
inputClassName="rounded-none bg-transparent text-sm"
placeholder="Search projects..."
showTrigger={false}
size="sm"
unstyled
value={projectQuery}
onChange={(event) => {
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();
}
}}
Comment thread
colonelpanic8 marked this conversation as resolved.
/>
</div>
</div>
{filteredProjects.length === 0 ? (
<div className="p-2 text-center text-base text-muted-foreground sm:text-sm" role="status">
No matching projects.
</div>
) : null}
<ComboboxListVirtualized className="flex max-h-64 min-h-0 flex-col overflow-hidden p-0">
<div className="min-h-0 flex-auto overflow-y-auto overscroll-contain p-1">
{filteredProjects.map((project) => {
const key = getDraftHeroProjectPickerItemValue(
scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
);
return (
<ComboboxItem key={key} value={key}>
<span className="min-w-0 truncate">{project.title}</span>
</ComboboxItem>
);
})}
Comment thread
cursor[bot] marked this conversation as resolved.
</div>
<div className="shrink-0 px-1 pb-1">
<ComboboxSeparator />
<ComboboxItem hideIndicator value={DRAFT_HERO_NEW_PROJECT_ITEM}>
<FolderPlusIcon />
New project
</ComboboxItem>
</div>
</ComboboxListVirtualized>
Comment thread
colonelpanic8 marked this conversation as resolved.
</ComboboxPopup>
</Combobox>
) : (
<button
type="button"
Expand Down
119 changes: 119 additions & 0 deletions apps/web/src/components/chat/draftHeroProjectSearch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vite-plus/test";

import {
DRAFT_HERO_NEW_PROJECT_ITEM,
filterDraftHeroProjects,
getDraftHeroFilteredPickerItems,
getDraftHeroProjectPickerItemValue,
resolveDraftHeroPickerKeyAction,
} from "./draftHeroProjectSearch";

const projects = [
{ title: "T3 Code", workspaceRoot: "/work/t3code" },
{ title: "Mobile App", workspaceRoot: "/work/products/mobile" },
{ title: "Marketing Site", workspaceRoot: "/work/products/marketing" },
];

describe("filterDraftHeroProjects", () => {
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");
});
});
Loading
Loading