diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index a217d53b5b7..f69c38e1a0f 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -111,12 +111,13 @@ export function buildProjectActionItems(input: { valuePrefix: string; icon: (project: Project) => ReactNode; runProject: (project: Project) => Promise; + searchTerms?: (project: Project) => ReadonlyArray; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ kind: "action", value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, - searchTerms: [project.title, project.workspaceRoot], + searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])], title: project.title, description: project.workspaceRoot, icon: input.icon(project), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4f330baf665..ffe3264fbf8 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -56,9 +56,10 @@ import { useEnvironmentQuery } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; -import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { + resolveThreadActionProjectRef, startNewThreadInProjectFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; @@ -79,7 +80,7 @@ import { } from "../lib/projectPaths"; import { onOpenCommandPalette } from "../commandPaletteBus"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { getLatestThreadForProject } from "../lib/threadSort"; +import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; @@ -106,6 +107,7 @@ import { ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, } from "./CommandPalette.logic"; +import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; @@ -128,6 +130,12 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; +import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { + buildSidebarProjectPickerEntries, + buildSidebarProjectSnapshots, +} from "../sidebarProjectGrouping"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; @@ -487,10 +495,11 @@ function OpenCommandPaletteDialog(props: { }); const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); - const primaryEnvironment = usePrimaryEnvironment(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); + const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providers = useAtomValue(primaryServerProvidersAtom); @@ -504,7 +513,93 @@ function OpenCommandPaletteDialog(props: { const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); - const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + const projectGroupingSettings = useMemo( + () => selectProjectGroupingSettings(clientSettings), + [clientSettings], + ); + + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const orderedProjects = useMemo( + () => + orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }), + [projectOrder, projects], + ); + const unsortedProjectGroups = useMemo( + () => + buildSidebarProjectSnapshots({ + projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + }), + [ + clientSettings.sidebarProjectSortOrder, + environmentLabelById, + orderedProjects, + primaryEnvironmentId, + projectGroupingSettings, + projects, + ], + ); + const projectGroups = useMemo( + () => + sortLogicalProjectsForSidebar( + unsortedProjectGroups, + threads, + clientSettings.sidebarProjectSortOrder, + ), + [clientSettings.sidebarProjectSortOrder, threads, unsortedProjectGroups], + ); + const contextualProjectRef = useMemo( + () => + resolveThreadActionProjectRef({ + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }), + [activeDraftThread, activeThread, defaultProjectRef, handleNewThread], + ); + const projectPickerEntries = useMemo( + () => + buildSidebarProjectPickerEntries({ + groups: projectGroups, + preferredProjectRef: contextualProjectRef, + }), + [contextualProjectRef, projectGroups], + ); + const pickerProjects = useMemo( + () => + projectPickerEntries.map(({ group, targetProject }) => ({ + ...targetProject, + title: group.displayName, + })), + [projectPickerEntries], + ); + const projectGroupByTargetKey = useMemo( + () => + new Map( + projectPickerEntries.map(({ group, targetProject }) => [ + `${targetProject.environmentId}:${targetProject.id}`, + group, + ]), + ), + [projectPickerEntries], + ); const addProjectEnvironmentOptions = useMemo(() => { const options = environments.map((environment): AddProjectEnvironmentOption => { @@ -648,11 +743,28 @@ function OpenCommandPaletteDialog(props: { const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => { - const latestThread = getLatestThreadForProject( - threads.filter((thread) => thread.environmentId === project.environmentId), - project.id, - clientSettings.sidebarThreadSortOrder, - ); + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const groupedProjectKeys = group + ? new Set( + group.memberProjectRefs.map( + (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, + ), + ) + : null; + const latestThread = groupedProjectKeys + ? (sortThreads( + threads.filter( + (thread) => + thread.archivedAt === null && + groupedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`), + ), + clientSettings.sidebarThreadSortOrder, + )[0] ?? null) + : getLatestThreadForProject( + threads.filter((thread) => thread.environmentId === project.environmentId), + project.id, + clientSettings.sidebarThreadSortOrder, + ); if (latestThread) { await navigate({ to: "/$environmentId/$threadId", @@ -665,14 +777,26 @@ function OpenCommandPaletteDialog(props: { await handleNewThread(scopeProjectRef(project.environmentId, project.id)); }, - [handleNewThread, navigate, clientSettings.sidebarThreadSortOrder, threads], + [ + clientSettings.sidebarThreadSortOrder, + handleNewThread, + navigate, + projectGroupByTargetKey, + threads, + ], ); const projectSearchItems = useMemo( () => buildProjectActionItems({ - projects, + projects: pickerProjects, valuePrefix: "project", + searchTerms: (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + return ( + group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] + ); + }, icon: (project) => ( enumerateCommandPaletteItems( buildProjectActionItems({ - projects, + projects: pickerProjects, valuePrefix: "new-thread-in", + searchTerms: (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + return ( + group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] + ); + }, icon: (project) => ( ), runProject: async (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const contextualRefBelongsToGroup = + contextualProjectRef !== null && + group?.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === contextualProjectRef.environmentId && + projectRef.projectId === contextualProjectRef.projectId, + ); await startNewThreadInProjectFromContext( { activeDraftThread, @@ -706,12 +844,22 @@ function OpenCommandPaletteDialog(props: { defaultProjectRef, handleNewThread, }, - scopeProjectRef(project.environmentId, project.id), + contextualRefBelongsToGroup + ? contextualProjectRef + : scopeProjectRef(project.environmentId, project.id), ); }, }), ), - [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], + [ + activeDraftThread, + activeThread, + contextualProjectRef, + defaultProjectRef, + handleNewThread, + pickerProjects, + projectGroupByTargetKey, + ], ); const allThreadItems = useMemo( @@ -1018,9 +1166,9 @@ function OpenCommandPaletteDialog(props: { const actionItems: Array = []; if (projects.length > 0) { - const activeProjectTitle = currentProjectId - ? (projectTitleById.get(currentProjectId) ?? null) - : null; + const activeProjectTitle = + projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? + (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null); if (activeProjectTitle) { actionItems.push({ diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 2078b0ffc41..f962f6cf151 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -573,7 +573,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Un-settle thread" onClick={handleUnsettleClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5" + className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > @@ -582,7 +582,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5" + className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > @@ -659,7 +659,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5" + className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > Settle @@ -823,10 +823,16 @@ export default function SidebarV2() { ), [projects], ); - const projectTitleByKey = useMemo( + const projectDisplayNameByKey = useMemo( () => - new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), - [projects], + new Map( + projectGroups.flatMap((group) => + group.memberProjects.map( + (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const, + ), + ), + ), + [projectGroups], ); // now is quantized to the minute so effectiveSettled memoization doesn't @@ -1768,7 +1774,8 @@ export default function SidebarV2() { projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null } projectTitle={ - projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null + projectDisplayNameByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + null } providerEntryByInstanceId={providerEntryByInstanceId} onThreadClick={handleThreadClick} diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 3f50d3818b5..98091f9aab6 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -5,8 +5,15 @@ 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 { sortScopedProjectsForSidebar } from "../Sidebar.logic"; +import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { Menu, MenuItem, @@ -28,29 +35,66 @@ export function DraftHeroHeadline({ }: 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 orderedProjects = useMemo( - () => sortScopedProjectsForSidebar(projects, threads, "updated_at"), - [projects, threads], - ); - const projectByKey = useMemo( + const environmentLabelById = useMemo( () => new Map( - orderedProjects.map( - (project) => - [ - scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - project, - ] as const, - ), + 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, ), - [orderedProjects], + [ + 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 activeProjectKey = activeProjectRef === null ? "" : scopedProjectKey(activeProjectRef); + 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 = orderedProjects.length > 0; + const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; const projectSelector = shouldShowProjectMenu ? ( @@ -59,26 +103,26 @@ export function DraftHeroHeadline({ aria-label={hasResolvedProject ? "Change project" : "Choose a project"} className="pointer-events-auto inline cursor-pointer border-current border-b border-dotted text-foreground underline-offset-8 transition-opacity hover:opacity-75 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" > - {activeProjectTitle ?? "Choose a project"} + {activeProjectDisplayName ?? "Choose a project"} { - const project = projectByKey.get(value as string); - if (!project || value === activeProjectKey) { + const entry = projectEntryByKey.get(value as string); + if (!entry || value === activeProjectKey) { return; } + const project = entry.targetProject; void handleNewThread(scopeProjectRef(project.environmentId, project.id), { replace: true, }); }} > - {orderedProjects.map((project) => { - const key = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); + {projectPickerEntries.map(({ group }) => { return ( - - {project.title} + + {group.displayName} ); })} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 57824fe5188..2e29d222b5b 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,11 +1,14 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { useClientSettings } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; +import { usePrimaryEnvironmentId } from "../state/environments"; +import { selectProjectGroupingSettings } from "../logicalProject"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -29,7 +32,19 @@ function ChatRouteGlobalShortcuts() { useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); - const projectCount = useProjects().length; + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const projects = useProjects(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupCount = useMemo( + () => + buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }).length, + [primaryEnvironmentId, projectGroupingSettings, projects], + ); const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -83,7 +98,7 @@ function ChatRouteGlobalShortcuts() { // Sidebar v2 routes creation through the command palette whenever // there is a real choice to make; v1 (and single-project setups) // keep the immediate contextual create. - if (sidebarV2Enabled && projectCount > 1) { + if (sidebarV2Enabled && projectGroupCount > 1) { openCommandPalette({ open: "new-thread-in" }); return; } @@ -152,7 +167,7 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, - projectCount, + projectGroupCount, routeThreadRef, selectedThreadKeysSize, sidebarV2Enabled,