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...@@ -3,13 +3,12 @@ import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDr
3import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";3import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
4import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";4import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
5import { isScratchProject } from "@t3tools/client-runtime/state/projects";5import { isScratchProject } from "@t3tools/client-runtime/state/projects";
6import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react";6import { FolderPlusIcon } from "lucide-react";
7import { useAtomValue } from "@effect/atom-react";7import { useAtomValue } from "@effect/atom-react";
8import { useCallback, useEffect, useMemo, useRef } from "react";8import { useCallback, useEffect, useMemo, useRef, useState } from "react";
99
10import { openCommandPalette } from "~/commandPaletteBus";10import { openCommandPalette } from "~/commandPaletteBus";
11import { shortcutLabelForCommand } from "~/keybindings";11import { shortcutLabelForCommand } from "~/keybindings";
12import { projectIconColorClassName } from "~/projectIconColors";
13import { primaryServerKeybindingsAtom } from "~/state/server";12import { primaryServerKeybindingsAtom } from "~/state/server";
14import { useNewThreadHandler } from "~/hooks/useHandleNewThread";13import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
15import { useScratchProject } from "~/hooks/useScratchProject";14import { useScratchProject } from "~/hooks/useScratchProject";
...@@ -30,20 +29,27 @@ import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge";...@@ -30,20 +29,27 @@ import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge";
30import { ProjectFavicon } from "../ProjectFavicon";29import { ProjectFavicon } from "../ProjectFavicon";
31import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";30import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";
32import {31import {
33 Menu,32 Combobox,
34 MenuItem,33 ComboboxEmpty,
35 MenuPopup,34 ComboboxItem,
36 MenuRadioGroup,35 ComboboxList,
37 MenuRadioItem,36 ComboboxPopup,
38 MenuSeparator,37 ComboboxSearchInput,
39 MenuTrigger,38 ComboboxTrigger,
40} from "../ui/menu";39 useComboboxFilter,
40} from "../ui/combobox";
41import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";41import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
42import { InlineButton } from "../ui/button";42import { InlineButton } from "../ui/button";
43import { resolveProjectSettings } from "@t3tools/shared/projectSettings";43import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
4444
45// Menu value for "No project"; real entries are keyed by logical project key.45// Picker value for the "Add project" row; real entries are keyed by logical
46const NO_PROJECT_VALUE = "no-project";46// project key.
47const ADD_PROJECT_VALUE = "add-project";
48
49interface PickerItem {
50 readonly value: string;
51 readonly label: string;
52}
4753
48interface DraftHeroHeadlineProps {54interface DraftHeroHeadlineProps {
49 readonly draftId: DraftId | null;55 readonly draftId: DraftId | null;
...@@ -147,11 +153,15 @@ export function DraftHeroHeadline({...@@ -147,11 +153,15 @@ export function DraftHeroHeadline({
147 const hasResolvedProject = activeProjectTitle !== null;153 const hasResolvedProject = activeProjectTitle !== null;
148 const canChooseProject = projectPickerEntries.length > 0;154 const canChooseProject = projectPickerEntries.length > 0;
149 const shouldShowProjectMenu = canChooseProject;155 const shouldShowProjectMenu = canChooseProject;
150 // The project that hosts threads without a project appears once, as the156 // The project that hosts threads without a project is not a row: the line
151 // "No project" item, not as a project row.157 // under the headline is the way into it.
152 const menuEntries = projectPickerEntries.filter(158 const menuEntries = useMemo(
153 ({ targetProject }) =>159 () =>
154 !isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)),160 projectPickerEntries.filter(
161 ({ targetProject }) =>
162 !isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)),
163 ),
164 [projectPickerEntries, scratchWorkspaceRootFor],
155 );165 );
156 const activeProject =166 const activeProject =
157 activeProjectRef === null167 activeProjectRef === null
...@@ -168,6 +178,30 @@ export function DraftHeroHeadline({...@@ -168,6 +178,30 @@ export function DraftHeroHeadline({
168 const isScratchDraft =178 const isScratchDraft =
169 activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRoot);179 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
171 // The picker can change the draft's target while the no-project home is205 // The picker can change the draft's target while the no-project home is
172 // still being opened; a stale continuation must not retarget it again.206 // still being opened; a stale continuation must not retarget it again.
173 const latestTargetRef = useRef({ draftId, activeProjectKey, scratchTargetEnvironmentId });207 const latestTargetRef = useRef({ draftId, activeProjectKey, scratchTargetEnvironmentId });
...@@ -234,7 +268,32 @@ export function DraftHeroHeadline({...@@ -234,7 +268,32 @@ export function DraftHeroHeadline({
234 };268 };
235269
236 const projectSelector = shouldShowProjectMenu ? (270 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 >
238 <Tooltip>297 <Tooltip>
239 <TooltipTrigger298 <TooltipTrigger
240 render={299 render={
...@@ -242,7 +301,7 @@ export function DraftHeroHeadline({...@@ -242,7 +301,7 @@ export function DraftHeroHeadline({
242 // project title) so the hero sentence reads naturally: an301 // project title) so the hero sentence reads naturally: an
243 // aria-label here would replace the title with an action phrase302 // aria-label here would replace the title with an action phrase
244 // mid-sentence and baffle screen-reader users.303 // mid-sentence and baffle screen-reader users.
245 <MenuTrigger304 <ComboboxTrigger
246 render={<InlineButton tone="picker" />}305 render={<InlineButton tone="picker" />}
247 data-draft-project-trigger=""306 data-draft-project-trigger=""
248 className="pointer-events-auto max-w-64 align-baseline"307 className="pointer-events-auto max-w-64 align-baseline"
...@@ -257,65 +316,43 @@ export function DraftHeroHeadline({...@@ -257,65 +316,43 @@ export function DraftHeroHeadline({
257 <TooltipPopup side="top">{activeProjectDisplayName}</TooltipPopup>316 <TooltipPopup side="top">{activeProjectDisplayName}</TooltipPopup>
258 ) : null}317 ) : null}
259 </Tooltip>318 </Tooltip>
260 <MenuPopup align="center" className="max-h-80 overflow-y-auto">319 <ComboboxPopup align="center" className="w-72 overflow-hidden">
261 <MenuRadioGroup320 <ComboboxSearchInput
262 value={isScratchDraft ? NO_PROJECT_VALUE : activeProjectKey}321 aria-label="Search projects"
263 onValueChange={(value) => {322 placeholder="Search projects..."
264 if (value === NO_PROJECT_VALUE) {323 value={pickerQuery}
265 void startScratch();324 onChange={(event) => setPickerQuery(event.target.value)}
266 return;325 />
267 }326 <ComboboxEmpty>No matching projects.</ComboboxEmpty>
268 const entry = projectEntryByKey.get(value as string);327 <ComboboxList>
269 if (!entry || value === activeProjectKey) {328 {(item: PickerItem) => {
270 return;329 const entry = projectEntryByKey.get(item.value);
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 }) => {
290 return (330 return (
291 <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>331 <ComboboxItem key={item.value} hideIndicator value={item}>
292 <span className="flex min-w-0 items-center gap-2">332 {item.value === ADD_PROJECT_VALUE ? (
293 <ProjectFavicon project={group} className="size-4 shrink-0" />333 <FolderPlusIcon className="size-4 shrink-0" />
294 <Tooltip>334 ) : entry ? (
295 <TooltipTrigger render={<span className="block min-w-0 truncate" />}>335 <ProjectFavicon project={entry.group} className="size-4 shrink-0" />
296 {group.displayName}336 ) : null}
297 </TooltipTrigger>337 <Tooltip>
298 <TooltipPopup side="top">{group.displayName}</TooltipPopup>338 <TooltipTrigger render={<span className="min-w-0 flex-1 truncate text-sm" />}>
299 </Tooltip>339 {item.label}
300 {showProjectEnvironments ? (340 </TooltipTrigger>
301 <ProjectEnvironmentBadge341 <TooltipPopup side="top">{item.label}</TooltipPopup>
302 group={group}342 </Tooltip>
303 primaryEnvironmentId={primaryEnvironmentId}343 {entry && showProjectEnvironments ? (
304 machineByEnvironmentId={environmentMachineById}344 <ProjectEnvironmentBadge
305 />345 group={entry.group}
306 ) : null}346 primaryEnvironmentId={primaryEnvironmentId}
307 </span>347 machineByEnvironmentId={environmentMachineById}
308 </MenuRadioItem>348 />
349 ) : null}
350 </ComboboxItem>
309 );351 );
310 })}352 }}
311 </MenuRadioGroup>353 </ComboboxList>
312 {projectPickerEntries.length > 0 ? <MenuSeparator /> : null}354 </ComboboxPopup>
313 <MenuItem onClick={openAddProject}>355 </Combobox>
314 <FolderPlusIcon />
315 Add project
316 </MenuItem>
317 </MenuPopup>
318 </Menu>
319 ) : (356 ) : (
320 <button357 <button
321 type="button"358 type="button"