authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 18:47:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 18:47:15-07:00
logb744bded016b15b9b5ade552a3e28270ac20176f
tree94858f3f15c8342e034c7ddc424c952d048aeb61
parent3e94afa7c26734f525a7d8a9cdb47896e8618cf8
signature Signed by PGP key B5690EEEBB952194

feat(web): draft screen project picker is searchable (#17664)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

1 files changed, 114 insertions(+), 77 deletions(-)

apps/web/src/components/chat/DraftHeroHeadline.tsx+114-77
......@@ -3,13 +3,12 @@ import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDr
33import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
44import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
55import { isScratchProject } from "@t3tools/client-runtime/state/projects";
6import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react";
6import { FolderPlusIcon } from "lucide-react";
77import { useAtomValue } from "@effect/atom-react";
8import { useCallback, useEffect, useMemo, useRef } from "react";
8import { useCallback, useEffect, useMemo, useRef, useState } from "react";
99
1010import { openCommandPalette } from "~/commandPaletteBus";
1111import { shortcutLabelForCommand } from "~/keybindings";
12import { projectIconColorClassName } from "~/projectIconColors";
1312import { primaryServerKeybindingsAtom } from "~/state/server";
1413import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
1514import { useScratchProject } from "~/hooks/useScratchProject";
......@@ -30,20 +29,27 @@ import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge";
3029import { ProjectFavicon } from "../ProjectFavicon";
3130import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";
3231import {
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";
4141import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4242import { InlineButton } from "../ui/button";
4343import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
4444
45// Menu value for "No project"; real entries are keyed by logical project key.
46const NO_PROJECT_VALUE = "no-project";
45// Picker value for the "Add project" row; real entries are keyed by logical
46// project key.
47const ADD_PROJECT_VALUE = "add-project";
48
49interface PickerItem {
50 readonly value: string;
51 readonly label: string;
52}
4753
4854interface 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,7 +268,32 @@ 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={
......@@ -242,7 +301,7 @@ export function DraftHeroHeadline({
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"