Skip to content

Commit b744bde

Browse files
feat(web): draft screen project picker is searchable (#17664)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3e94afa commit b744bde

1 file changed

Lines changed: 114 additions & 77 deletions

File tree

‎apps/web/src/components/chat/DraftHeroHeadline.tsx‎

Lines changed: 114 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@ import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDr
33
import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
44
import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
55
import { isScratchProject } from "@t3tools/client-runtime/state/projects";
6-
import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react";
6+
import { FolderPlusIcon } from "lucide-react";
77
import { useAtomValue } from "@effect/atom-react";
8-
import { useCallback, useEffect, useMemo, useRef } from "react";
8+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
99

1010
import { openCommandPalette } from "~/commandPaletteBus";
1111
import { shortcutLabelForCommand } from "~/keybindings";
12-
import { projectIconColorClassName } from "~/projectIconColors";
1312
import { primaryServerKeybindingsAtom } from "~/state/server";
1413
import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
1514
import { useScratchProject } from "~/hooks/useScratchProject";
@@ -30,20 +29,27 @@ import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge";
3029
import { ProjectFavicon } from "../ProjectFavicon";
3130
import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";
3231
import {
33-
Menu,
34-
MenuItem,
35-
MenuPopup,
36-
MenuRadioGroup,
37-
MenuRadioItem,
38-
MenuSeparator,
39-
MenuTrigger,
40-
} from "../ui/menu";
32+
Combobox,
33+
ComboboxEmpty,
34+
ComboboxItem,
35+
ComboboxList,
36+
ComboboxPopup,
37+
ComboboxSearchInput,
38+
ComboboxTrigger,
39+
useComboboxFilter,
40+
} from "../ui/combobox";
4141
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4242
import { InlineButton } from "../ui/button";
4343
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
4444

45-
// Menu value for "No project"; real entries are keyed by logical project key.
46-
const NO_PROJECT_VALUE = "no-project";
45+
// Picker value for the "Add project" row; real entries are keyed by logical
46+
// project key.
47+
const ADD_PROJECT_VALUE = "add-project";
48+
49+
interface PickerItem {
50+
readonly value: string;
51+
readonly label: string;
52+
}
4753

4854
interface DraftHeroHeadlineProps {
4955
readonly draftId: DraftId | null;
@@ -147,11 +153,15 @@ export function DraftHeroHeadline({
147153
const hasResolvedProject = activeProjectTitle !== null;
148154
const canChooseProject = projectPickerEntries.length > 0;
149155
const shouldShowProjectMenu = canChooseProject;
150-
// The project that hosts threads without a project appears once, as the
151-
// "No project" item, not as a project row.
152-
const menuEntries = projectPickerEntries.filter(
153-
({ targetProject }) =>
154-
!isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)),
156+
// The project that hosts threads without a project is not a row: the line
157+
// under the headline is the way into it.
158+
const menuEntries = useMemo(
159+
() =>
160+
projectPickerEntries.filter(
161+
({ targetProject }) =>
162+
!isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)),
163+
),
164+
[projectPickerEntries, scratchWorkspaceRootFor],
155165
);
156166
const activeProject =
157167
activeProjectRef === null
@@ -168,6 +178,30 @@ export function DraftHeroHeadline({
168178
const isScratchDraft =
169179
activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRoot);
170180

181+
// {value, label} items let Base UI drive the combobox selection while the
182+
// popup search filters the same collection. "Add project" is an action, not
183+
// a project, so it only trails the unfiltered list.
184+
const pickerItems = useMemo<readonly PickerItem[]>(
185+
() => [
186+
...menuEntries.map(({ group }) => ({ value: group.projectKey, label: group.displayName })),
187+
{ value: ADD_PROJECT_VALUE, label: "Add project" },
188+
],
189+
[menuEntries],
190+
);
191+
const [pickerOpen, setPickerOpen] = useState(false);
192+
const [pickerQuery, setPickerQuery] = useState("");
193+
const pickerFilter = useComboboxFilter();
194+
const filteredPickerItems = useMemo(() => {
195+
const query = pickerQuery.trim();
196+
if (query.length === 0) return pickerItems;
197+
return pickerItems.filter(
198+
(item) =>
199+
item.value !== ADD_PROJECT_VALUE &&
200+
pickerFilter.contains(item, query, (candidate) => candidate.label),
201+
);
202+
}, [pickerFilter, pickerItems, pickerQuery]);
203+
const selectedPickerItem = pickerItems.find((item) => item.value === activeProjectKey) ?? null;
204+
171205
// The picker can change the draft's target while the no-project home is
172206
// still being opened; a stale continuation must not retarget it again.
173207
const latestTargetRef = useRef({ draftId, activeProjectKey, scratchTargetEnvironmentId });
@@ -234,15 +268,40 @@ export function DraftHeroHeadline({
234268
};
235269

236270
const projectSelector = shouldShowProjectMenu ? (
237-
<Menu>
271+
<Combobox
272+
items={pickerItems}
273+
filteredItems={filteredPickerItems}
274+
autoHighlight
275+
itemToStringLabel={(item) => item.label}
276+
isItemEqualToValue={(a, b) => a.value === b.value}
277+
open={pickerOpen}
278+
onOpenChange={(open) => {
279+
setPickerOpen(open);
280+
setPickerQuery("");
281+
}}
282+
value={selectedPickerItem}
283+
onValueChange={(item) => {
284+
if (!item) return;
285+
setPickerOpen(false);
286+
if (item.value === ADD_PROJECT_VALUE) {
287+
openAddProject();
288+
return;
289+
}
290+
const entry = projectEntryByKey.get(item.value);
291+
if (!entry || item.value === activeProjectKey) {
292+
return;
293+
}
294+
selectProject(entry.targetProject, entry.group.projectKey);
295+
}}
296+
>
238297
<Tooltip>
239298
<TooltipTrigger
240299
render={
241300
// The trigger's accessible name comes from its visible text (the
242301
// project title) so the hero sentence reads naturally: an
243302
// aria-label here would replace the title with an action phrase
244303
// mid-sentence and baffle screen-reader users.
245-
<MenuTrigger
304+
<ComboboxTrigger
246305
render={<InlineButton tone="picker" />}
247306
data-draft-project-trigger=""
248307
className="pointer-events-auto max-w-64 align-baseline"
@@ -257,65 +316,43 @@ export function DraftHeroHeadline({
257316
<TooltipPopup side="top">{activeProjectDisplayName}</TooltipPopup>
258317
) : null}
259318
</Tooltip>
260-
<MenuPopup align="center" className="max-h-80 overflow-y-auto">
261-
<MenuRadioGroup
262-
value={isScratchDraft ? NO_PROJECT_VALUE : activeProjectKey}
263-
onValueChange={(value) => {
264-
if (value === NO_PROJECT_VALUE) {
265-
void startScratch();
266-
return;
267-
}
268-
const entry = projectEntryByKey.get(value as string);
269-
if (!entry || value === activeProjectKey) {
270-
return;
271-
}
272-
selectProject(entry.targetProject, entry.group.projectKey);
273-
}}
274-
>
275-
{scratchWorkspaceRoot === null ? null : (
276-
<MenuRadioItem value={NO_PROJECT_VALUE} closeOnClick>
277-
<span className="flex min-w-0 items-center gap-2">
278-
{/* Boxed like ProjectFavicon so the label lines up with project rows. */}
279-
<span
280-
aria-hidden="true"
281-
className={`inline-flex size-4 shrink-0 ${projectIconColorClassName("gray")}`}
282-
>
283-
<MessageSquareDashedIcon className="size-full" />
284-
</span>
285-
No project
286-
</span>
287-
</MenuRadioItem>
288-
)}
289-
{menuEntries.map(({ group }) => {
319+
<ComboboxPopup align="center" className="w-72 overflow-hidden">
320+
<ComboboxSearchInput
321+
aria-label="Search projects"
322+
placeholder="Search projects..."
323+
value={pickerQuery}
324+
onChange={(event) => setPickerQuery(event.target.value)}
325+
/>
326+
<ComboboxEmpty>No matching projects.</ComboboxEmpty>
327+
<ComboboxList>
328+
{(item: PickerItem) => {
329+
const entry = projectEntryByKey.get(item.value);
290330
return (
291-
<MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>
292-
<span className="flex min-w-0 items-center gap-2">
293-
<ProjectFavicon project={group} className="size-4 shrink-0" />
294-
<Tooltip>
295-
<TooltipTrigger render={<span className="block min-w-0 truncate" />}>
296-
{group.displayName}
297-
</TooltipTrigger>
298-
<TooltipPopup side="top">{group.displayName}</TooltipPopup>
299-
</Tooltip>
300-
{showProjectEnvironments ? (
301-
<ProjectEnvironmentBadge
302-
group={group}
303-
primaryEnvironmentId={primaryEnvironmentId}
304-
machineByEnvironmentId={environmentMachineById}
305-
/>
306-
) : null}
307-
</span>
308-
</MenuRadioItem>
331+
<ComboboxItem key={item.value} hideIndicator value={item}>
332+
{item.value === ADD_PROJECT_VALUE ? (
333+
<FolderPlusIcon className="size-4 shrink-0" />
334+
) : entry ? (
335+
<ProjectFavicon project={entry.group} className="size-4 shrink-0" />
336+
) : null}
337+
<Tooltip>
338+
<TooltipTrigger render={<span className="min-w-0 flex-1 truncate text-sm" />}>
339+
{item.label}
340+
</TooltipTrigger>
341+
<TooltipPopup side="top">{item.label}</TooltipPopup>
342+
</Tooltip>
343+
{entry && showProjectEnvironments ? (
344+
<ProjectEnvironmentBadge
345+
group={entry.group}
346+
primaryEnvironmentId={primaryEnvironmentId}
347+
machineByEnvironmentId={environmentMachineById}
348+
/>
349+
) : null}
350+
</ComboboxItem>
309351
);
310-
})}
311-
</MenuRadioGroup>
312-
{projectPickerEntries.length > 0 ? <MenuSeparator /> : null}
313-
<MenuItem onClick={openAddProject}>
314-
<FolderPlusIcon />
315-
Add project
316-
</MenuItem>
317-
</MenuPopup>
318-
</Menu>
352+
}}
353+
</ComboboxList>
354+
</ComboboxPopup>
355+
</Combobox>
319356
) : (
320357
<button
321358
type="button"

0 commit comments

Comments
 (0)