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 { openCommandPalette } from "~/commandPaletteBus";import { useNewThreadHandler } from "~/hooks/useHandleNewThread";import { useClientSettings } from "~/hooks/useSettings";import { selectProjectGroupingSettings } from "~/logicalProject";import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots,} from "~/sidebarProjectGrouping";import { useProjects, useThreadShells } from "~/state/entities";import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";import { Menu, MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger,} from "../ui/menu";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; interface DraftHeroHeadlineProps { readonly activeProjectRef: ScopedProjectRef | null; readonly activeProjectTitle: string | null;} export function DraftHeroHeadline({ activeProjectRef, activeProjectTitle,}: DraftHeroHeadlineProps) { const projects = useProjects(); const threads = useThreadShells(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); const handleNewThread = useNewThreadHandler(); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); const environmentLabelById = useMemo( () => new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ), [environments], ); const projectGroups = useMemo( () => sortLogicalProjectsForSidebar( buildSidebarProjectSnapshots({ projects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }), threads, projectSortOrder, ), [ environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projectSortOrder, projects, threads, ], ); const projectPickerEntries = useMemo( () => buildSidebarProjectPickerEntries({ groups: projectGroups, preferredProjectRef: activeProjectRef, }), [activeProjectRef, projectGroups], ); const projectEntryByKey = useMemo( () => new Map(projectPickerEntries.map((entry) => [entry.group.projectKey, entry] as const)), [projectPickerEntries], ); const activeProjectGroup = activeProjectRef === null ? null : (projectGroups.find((group) => group.memberProjectRefs.some( (projectRef) => scopedProjectKey(projectRef) === scopedProjectKey(activeProjectRef), ), ) ?? null); const activeProjectKey = activeProjectGroup?.projectKey ?? ""; const activeProjectDisplayName = activeProjectGroup?.displayName ?? activeProjectTitle; const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; const projectSelector = shouldShowProjectMenu ? ( <Menu> <Tooltip> <TooltipTrigger render={ <MenuTrigger aria-label={hasResolvedProject ? "Change project" : "Choose a project"} className="pointer-events-auto inline-block max-w-64 truncate border-foreground/60 border-b border-dotted align-baseline text-foreground transition-colors hover:border-foreground/80 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" /> } > {activeProjectDisplayName ?? "Choose a project"} </TooltipTrigger> {activeProjectDisplayName ? ( <TooltipPopup side="top" className="max-w-80"> {activeProjectDisplayName} </TooltipPopup> ) : null} </Tooltip> <MenuPopup align="center" className="max-h-80 min-w-40! w-max max-w-64 overflow-y-auto"> <MenuRadioGroup value={activeProjectKey} onValueChange={(value) => { const entry = projectEntryByKey.get(value as string); if (!entry || value === activeProjectKey) { return; } const project = entry.targetProject; // Changing the repo of a draft moves the typed content along: // the user started writing in the wrong project, not a new task. void handleNewThread(scopeProjectRef(project.environmentId, project.id), { replace: true, carryComposerContent: true, }); }} > {projectPickerEntries.map(({ group }) => { return ( <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick> <Tooltip> <TooltipTrigger render={<span className="block min-w-0 truncate" />}> {group.displayName} </TooltipTrigger> <TooltipPopup side="top" className="max-w-80"> {group.displayName} </TooltipPopup> </Tooltip> </MenuRadioItem> ); })} </MenuRadioGroup> <MenuSeparator /> <MenuItem onClick={openAddProject}> <FolderPlusIcon /> New project </MenuItem> </MenuPopup> </Menu> ) : ( <button type="button" onClick={openAddProject} 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-ring" > {activeProjectTitle ?? "Add a project"} </button> ); return ( <h1 className="mx-auto w-full max-w-5xl text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl"> {hasResolvedProject ? ( <>What should we build in {projectSelector}?</> ) : canChooseProject ? ( <>{projectSelector} to start</> ) : ( <>Add a project to start</> )} </h1> );}
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 { openCommandPalette } from "~/commandPaletteBus";
import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
import { useClientSettings } from "~/hooks/useSettings";
import { selectProjectGroupingSettings } from "~/logicalProject";
import {
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
} from "~/sidebarProjectGrouping";
import { useProjects, useThreadShells } from "~/state/entities";
import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";
import {
Menu,
MenuItem,
MenuPopup,
MenuRadioGroup,
MenuRadioItem,
MenuSeparator,
MenuTrigger,
} from "../ui/menu";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
interface DraftHeroHeadlineProps {
readonly activeProjectRef: ScopedProjectRef | null;
readonly activeProjectTitle: string | null;
}
export function DraftHeroHeadline({
activeProjectRef,
activeProjectTitle,
}: DraftHeroHeadlineProps) {
const projects = useProjects();
const threads = useThreadShells();
const { environments } = useEnvironments();
const primaryEnvironmentId = usePrimaryEnvironmentId();
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder);
const handleNewThread = useNewThreadHandler();
const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
const environmentLabelById = useMemo(
() =>
new Map(
environments.map((environment) => [environment.environmentId, environment.label] as const),
),
[environments],
);
const projectGroups = useMemo(
() =>
sortLogicalProjectsForSidebar(
buildSidebarProjectSnapshots({
projects,
settings: projectGroupingSettings,
primaryEnvironmentId,
resolveEnvironmentLabel: (environmentId) =>
environmentLabelById.get(environmentId) ?? null,
}),
threads,
projectSortOrder,
),
[
environmentLabelById,
primaryEnvironmentId,
projectGroupingSettings,
projectSortOrder,
projects,
threads,
],
);
const projectPickerEntries = useMemo(
() =>
buildSidebarProjectPickerEntries({
groups: projectGroups,
preferredProjectRef: activeProjectRef,
}),
[activeProjectRef, projectGroups],
);
const projectEntryByKey = useMemo(
() => new Map(projectPickerEntries.map((entry) => [entry.group.projectKey, entry] as const)),
[projectPickerEntries],
);
const activeProjectGroup =
activeProjectRef === null
? null
: (projectGroups.find((group) =>
group.memberProjectRefs.some(
(projectRef) => scopedProjectKey(projectRef) === scopedProjectKey(activeProjectRef),
),
) ?? null);
const activeProjectKey = activeProjectGroup?.projectKey ?? "";
const activeProjectDisplayName = activeProjectGroup?.displayName ?? activeProjectTitle;
const hasResolvedProject = activeProjectTitle !== null;
const canChooseProject = projectPickerEntries.length > 0;
const shouldShowProjectMenu = canChooseProject;
const projectSelector = shouldShowProjectMenu ? (
<Menu>
<Tooltip>
<TooltipTrigger
render={
<MenuTrigger
aria-label={hasResolvedProject ? "Change project" : "Choose a project"}
className="pointer-events-auto inline-block max-w-64 truncate border-foreground/60 border-b border-dotted align-baseline text-foreground transition-colors hover:border-foreground/80 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
{activeProjectDisplayName ?? "Choose a project"}
</TooltipTrigger>
{activeProjectDisplayName ? (
<TooltipPopup side="top" className="max-w-80">
{activeProjectDisplayName}
</TooltipPopup>
) : null}
</Tooltip>
<MenuPopup align="center" className="max-h-80 min-w-40! w-max max-w-64 overflow-y-auto">
<MenuRadioGroup
value={activeProjectKey}
onValueChange={(value) => {
const entry = projectEntryByKey.get(value as string);
if (!entry || value === activeProjectKey) {
return;
}
const project = entry.targetProject;
// Changing the repo of a draft moves the typed content along:
// the user started writing in the wrong project, not a new task.
void handleNewThread(scopeProjectRef(project.environmentId, project.id), {
replace: true,
carryComposerContent: true,
});
}}
>
{projectPickerEntries.map(({ group }) => {
return (
<MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>
<Tooltip>
<TooltipTrigger render={<span className="block min-w-0 truncate" />}>
{group.displayName}
</TooltipTrigger>
<TooltipPopup side="top" className="max-w-80">
{group.displayName}
</TooltipPopup>
</Tooltip>
</MenuRadioItem>
);
})}
</MenuRadioGroup>
<MenuSeparator />
<MenuItem onClick={openAddProject}>
<FolderPlusIcon />
New project
</MenuItem>
</MenuPopup>
</Menu>
) : (
<button
type="button"
onClick={openAddProject}
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-ring"
>
{activeProjectTitle ?? "Add a project"}
</button>
);
return (
<h1 className="mx-auto w-full max-w-5xl text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl">
{hasResolvedProject ? (
<>What should we build in {projectSelector}?</>
) : canChooseProject ? (
<>{projectSelector} to start</>
) : (
<>Add a project to start</>
)}
</h1>
);
}