1import type { DraftId } from "~/composerDraftStore";
2import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDraftStore";
3import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts";
4import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
5import { isScratchProject } from "@t3tools/client-runtime/state/projects";
6import { FolderPlusIcon } from "lucide-react";
7import { useAtomValue } from "@effect/atom-react";
8import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
10import { openCommandPalette } from "~/commandPaletteBus";
11import { shortcutLabelForCommand } from "~/keybindings";
12import { primaryServerKeybindingsAtom } from "~/state/server";
13import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
14import { useScratchProject } from "~/hooks/useScratchProject";
15import { useClientSettings } from "~/hooks/useSettings";
16import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions";
17import {
18 deriveLogicalProjectKeyFromSettings,
19 selectProjectGroupingSettings,
20} from "~/logicalProject";
21import {
22 buildSidebarProjectPickerEntries,
23 buildSidebarProjectSnapshots,
24 projectGroupsSpanEnvironments,
25} from "~/sidebarProjectGrouping";
26import { useProjects, useThreadShells } from "~/state/entities";
27import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
28import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge";
29import { ProjectFavicon } from "../ProjectFavicon";
30import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";
31import {
32 Combobox,
33 ComboboxEmpty,
34 ComboboxItem,
35 ComboboxList,
36 ComboboxPopup,
37 ComboboxSearchInput,
38 ComboboxTrigger,
39 useComboboxFilter,
40} from "../ui/combobox";
41import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
42import { InlineButton } from "../ui/button";
43import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
44
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}
53
54interface DraftHeroHeadlineProps {
55 readonly draftId: DraftId | null;
56 readonly activeProjectRef: ScopedProjectRef | null;
57 readonly activeProjectTitle: string | null;
58}
59
60export function DraftHeroHeadline({
61 draftId,
62 activeProjectRef,
63 activeProjectTitle,
64}: DraftHeroHeadlineProps) {
65 const projects = useProjects();
66 const threads = useThreadShells();
67 const { environments } = useEnvironments();
68 const primaryEnvironmentId = usePrimaryEnvironmentId();
69 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
70 const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder);
71 const setLogicalProjectDraftThreadId = useComposerDraftStore(
72 (store) => store.setLogicalProjectDraftThreadId,
73 );
74 const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
75 const applyStickyState = useComposerDraftStore((store) => store.applyStickyState);
76 const setModelSelection = useComposerDraftStore((store) => store.setModelSelection);
77 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
78 const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject();
79 const openProjectDraft = useNewThreadHandler();
80 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
81
82 const environmentLabelById = useMemo(
83 () =>
84 new Map(
85 environments.map((environment) => [environment.environmentId, environment.label] as const),
86 ),
87 [environments],
88 );
89 const projectGroups = useMemo(
90 () =>
91 sortLogicalProjectsForSidebar(
92 buildSidebarProjectSnapshots({
93 projects,
94 settings: projectGroupingSettings,
95 primaryEnvironmentId,
96 resolveEnvironmentLabel: (environmentId) =>
97 environmentLabelById.get(environmentId) ?? null,
98 }),
99 threads,
100 projectSortOrder,
101 ),
102 [
103 environmentLabelById,
104 primaryEnvironmentId,
105 projectGroupingSettings,
106 projectSortOrder,
107 projects,
108 threads,
109 ],
110 );
111 // Same-named projects on two machines are only told apart by where they
112 // live, so rows on another machine carry its icon once the catalog spans
113 // more than one environment; a single-machine catalog stays as it was.
114 const showProjectEnvironments = useMemo(
115 () => projectGroupsSpanEnvironments(projectGroups),
116 [projectGroups],
117 );
118 const environmentMachineById = useMemo(
119 () =>
120 new Map(
121 environments.map(
122 (environment) =>
123 [
124 environment.environmentId,
125 resolveEnvironmentMachineKind(environment.serverConfig),
126 ] as const,
127 ),
128 ),
129 [environments],
130 );
131 const projectPickerEntries = useMemo(
132 () =>
133 buildSidebarProjectPickerEntries({
134 groups: projectGroups,
135 preferredProjectRef: activeProjectRef,
136 }),
137 [activeProjectRef, projectGroups],
138 );
139 const projectEntryByKey = useMemo(
140 () => new Map(projectPickerEntries.map((entry) => [entry.group.projectKey, entry] as const)),
141 [projectPickerEntries],
142 );
143 const activeProjectGroup =
144 activeProjectRef === null
145 ? null
146 : (projectGroups.find((group) =>
147 group.memberProjectRefs.some(
148 (projectRef) => scopedProjectKey(projectRef) === scopedProjectKey(activeProjectRef),
149 ),
150 ) ?? null);
151 const activeProjectKey = activeProjectGroup?.projectKey ?? "";
152 const activeProjectDisplayName = activeProjectGroup?.displayName ?? activeProjectTitle;
153 const hasResolvedProject = activeProjectTitle !== null;
154 const canChooseProject = projectPickerEntries.length > 0;
155 const shouldShowProjectMenu = canChooseProject;
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],
165 );
166 const activeProject =
167 activeProjectRef === null
168 ? null
169 : (projects.find(
170 (project) =>
171 project.environmentId === activeProjectRef.environmentId &&
172 project.id === activeProjectRef.projectId,
173 ) ?? null);
174 const scratchTargetEnvironmentId = scratchEnvironmentId(
175 activeProjectRef?.environmentId ?? primaryEnvironmentId,
176 );
177 const scratchWorkspaceRoot = scratchWorkspaceRootFor(scratchTargetEnvironmentId);
178 const isScratchDraft =
179 activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRoot);
180
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
205 // The picker can change the draft's target while the no-project home is
206 // still being opened; a stale continuation must not retarget it again.
207 const latestTargetRef = useRef({ draftId, activeProjectKey, scratchTargetEnvironmentId });
208 useEffect(() => {
209 latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId };
210 }, [activeProjectKey, scratchTargetEnvironmentId, draftId]);
211 // With a prompt typed, project selection changes the target of the open
212 // draft in place, so the prompt stays in the same composer session. An empty
213 // draft instead opens the chosen project's own draft, like starting a new
214 // thread there: moving it would replace that draft and strand whatever it
215 // holds, such as the browser tabs of the no-project draft.
216 const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => {
217 if (!draftId) {
218 return;
219 }
220 latestTargetRef.current = {
221 draftId,
222 activeProjectKey: logicalProjectKey,
223 scratchTargetEnvironmentId: project.environmentId,
224 };
225 const currentDraft = getComposerDraft(draftId);
226 if (!composerDraftHasUserContent(currentDraft)) {
227 void openProjectDraft(scopeProjectRef(project.environmentId, project.id));
228 return;
229 }
230 setLogicalProjectDraftThreadId(
231 logicalProjectKey,
232 scopeProjectRef(project.environmentId, project.id),
233 draftId,
234 );
235 if (!hasExplicitComposerModelSelection(currentDraft)) {
236 applyStickyState(draftId);
237 const environmentSettings = environments.find(
238 (environment) => environment.environmentId === project.environmentId,
239 )?.serverConfig?.settings;
240 const defaultModelSelection = environmentSettings
241 ? resolveProjectSettings(environmentSettings, project.id, project).settings
242 .defaultModelSelection
243 : project.defaultModelSelection;
244 if (defaultModelSelection) {
245 setModelSelection(draftId, defaultModelSelection, {
246 replaceOptions: true,
247 });
248 }
249 }
250 };
251 const startScratch = async (): Promise<boolean> => {
252 if (scratchTargetEnvironmentId === null || isScratchDraft) {
253 return false;
254 }
255 const requested = { draftId, activeProjectKey, scratchTargetEnvironmentId };
256 const project = await openScratchProject(scratchTargetEnvironmentId);
257 const latest = latestTargetRef.current;
258 if (
259 !project ||
260 latest.draftId !== requested.draftId ||
261 latest.activeProjectKey !== requested.activeProjectKey ||
262 latest.scratchTargetEnvironmentId !== requested.scratchTargetEnvironmentId
263 ) {
264 return false;
265 }
266 selectProject(project, deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings));
267 return true;
268 };
269
270 const projectSelector = shouldShowProjectMenu ? (
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 >
297 <Tooltip>
298 <TooltipTrigger
299 render={
300 // The trigger's accessible name comes from its visible text (the
301 // project title) so the hero sentence reads naturally: an
302 // aria-label here would replace the title with an action phrase
303 // mid-sentence and baffle screen-reader users.
304 <ComboboxTrigger
305 render={<InlineButton tone="picker" />}
306 data-draft-project-trigger=""
307 className="pointer-events-auto max-w-64 align-baseline"
308 />
309 }
310 >
311 <span className="min-w-0 truncate">
312 {isScratchDraft ? "No project" : (activeProjectDisplayName ?? "Choose a project")}
313 </span>
314 </TooltipTrigger>
315 {activeProjectDisplayName && !isScratchDraft ? (
316 <TooltipPopup side="top">{activeProjectDisplayName}</TooltipPopup>
317 ) : null}
318 </Tooltip>
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);
330 return (
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>
351 );
352 }}
353 </ComboboxList>
354 </ComboboxPopup>
355 </Combobox>
356 ) : (
357 <button
358 type="button"
359 onClick={openAddProject}
360 className="pointer-events-auto inline cursor-pointer border-muted-foreground/35 border-b border-dotted text-muted-foreground/60 transition-colors hover:border-muted-foreground/60 hover:text-muted-foreground/80 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
361 >
362 {activeProjectTitle ?? "Add a project"}
363 </button>
364 );
365
366 // The composer hero is a sentence, so the heading's accessible name must be
367 // a complete sentence too. The project picker is a control rendered inline
368 // in the h1; without an explicit label its widget state bleeds into the
369 // announced phrase.
370 const headingLabel = isScratchDraft
371 ? "What should we work on?"
372 : hasResolvedProject
373 ? `What should we build in ${activeProjectDisplayName}?`
374 : canChooseProject
375 ? `${activeProjectDisplayName ?? "Choose a project"} to start`
376 : "Add a project to start";
377
378 // One click out of the project, phrased as the alternative to the question
379 // above it. Focus moves to the project picker once this line has gone.
380 const noProjectShortcut = shortcutLabelForCommand(keybindings, "chat.newWithoutProject");
381 const orStartWithoutProject =
382 scratchWorkspaceRoot !== null && !isScratchDraft && (hasResolvedProject || canChooseProject) ? (
383 <Tooltip>
384 <TooltipTrigger
385 render={
386 <InlineButton
387 tone="muted"
388 className="pointer-events-auto"
389 onClick={() =>
390 void startScratch().then((started) => {
391 if (started) {
392 document.querySelector<HTMLElement>("[data-draft-project-trigger]")?.focus();
393 }
394 })
395 }
396 />
397 }
398 >
399 or start without a project
400 </TooltipTrigger>
401 {noProjectShortcut ? <TooltipPopup side="bottom">{noProjectShortcut}</TooltipPopup> : null}
402 </Tooltip>
403 ) : null;
404
405 return (
406 <div className="mx-auto flex w-full max-w-5xl flex-col items-center">
407 <h1
408 aria-label={headingLabel}
409 className="w-full text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl"
410 >
411 {isScratchDraft ? (
412 <>What should we work on?</>
413 ) : hasResolvedProject ? (
414 <>What should we build in {projectSelector}?</>
415 ) : canChooseProject ? (
416 <>{projectSelector} to start</>
417 ) : (
418 <>Add a project to start</>
419 )}
420 </h1>
421 {/* Reserved whenever threads can skip a project, so the heading does not
422 move. Without a project, the picker moves here to choose one. */}
423 {scratchWorkspaceRoot === null ? null : (
424 <p className="mt-2 flex h-6 items-center text-sm">
425 {isScratchDraft ? projectSelector : orStartWithoutProject}
426 </p>
427 )}
428 </div>
429 );
430}