authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-23 18:11:48+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 16:11:48+00:00
log9d9208cec5ac6a3ddb61647644da2b99eee59bff
tree68255f7b5ca7e0009130e9a78604811d16f63e0c
parent57100fba854fb1214ae7a413111efae14443d445
signature Signed by PGP key B5690EEEBB952194

[codex] Group projects in new-thread pickers (#4312)

Co-authored-by: codex <codex@users.noreply.github.com>

5 files changed, 268 insertions(+), 53 deletions(-)

apps/web/src/components/CommandPalette.logic.ts+2-1
...@@ -111,12 +111,13 @@ export function buildProjectActionItems(input: {...@@ -111,12 +111,13 @@ export function buildProjectActionItems(input: {
111 valuePrefix: string;111 valuePrefix: string;
112 icon: (project: Project) => ReactNode;112 icon: (project: Project) => ReactNode;
113 runProject: (project: Project) => Promise<void>;113 runProject: (project: Project) => Promise<void>;
114 searchTerms?: (project: Project) => ReadonlyArray<string>;
114 shortcutCommand?: KeybindingCommand;115 shortcutCommand?: KeybindingCommand;
115}): CommandPaletteActionItem[] {116}): CommandPaletteActionItem[] {
116 return input.projects.map((project) => ({117 return input.projects.map((project) => ({
117 kind: "action",118 kind: "action",
118 value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,119 value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,
119 searchTerms: [project.title, project.workspaceRoot],120 searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])],
120 title: project.title,121 title: project.title,
121 description: project.workspaceRoot,122 description: project.workspaceRoot,
122 icon: input.icon(project),123 icon: input.icon(project),
apps/web/src/components/CommandPalette.tsx+166-18
...@@ -56,9 +56,10 @@ import { useEnvironmentQuery } from "../state/query";...@@ -56,9 +56,10 @@ import { useEnvironmentQuery } from "../state/query";
56import { sourceControlEnvironment } from "../state/sourceControl";56import { sourceControlEnvironment } from "../state/sourceControl";
57import { useAtomCommand } from "../state/use-atom-command";57import { useAtomCommand } from "../state/use-atom-command";
58import { useAtomQueryRunner } from "../state/use-atom-query-runner";58import { useAtomQueryRunner } from "../state/use-atom-query-runner";
59import { useEnvironments, usePrimaryEnvironment } from "../state/environments";59import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
60import { useProjects, useThreadShells } from "../state/entities";60import { useProjects, useThreadShells } from "../state/entities";
61import {61import {
62 resolveThreadActionProjectRef,
62 startNewThreadInProjectFromContext,63 startNewThreadInProjectFromContext,
63 startNewThreadFromContext,64 startNewThreadFromContext,
64} from "../lib/chatThreadActions";65} from "../lib/chatThreadActions";
...@@ -79,7 +80,7 @@ import {...@@ -79,7 +80,7 @@ import {
79} from "../lib/projectPaths";80} from "../lib/projectPaths";
80import { onOpenCommandPalette } from "../commandPaletteBus";81import { onOpenCommandPalette } from "../commandPaletteBus";
81import { isTerminalFocused } from "../lib/terminalFocus";82import { isTerminalFocused } from "../lib/terminalFocus";
82import { getLatestThreadForProject } from "../lib/threadSort";83import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";
83import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";84import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";
84import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";85import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
85import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";86import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
...@@ -106,6 +107,7 @@ import {...@@ -106,6 +107,7 @@ import {
106 ITEM_ICON_CLASS,107 ITEM_ICON_CLASS,
107 RECENT_THREAD_LIMIT,108 RECENT_THREAD_LIMIT,
108} from "./CommandPalette.logic";109} from "./CommandPalette.logic";
110import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";
109import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";111import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
110import { CommandPaletteResults } from "./CommandPaletteResults";112import { CommandPaletteResults } from "./CommandPaletteResults";
111import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";113import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";
...@@ -128,6 +130,12 @@ import { stackedThreadToast, toastManager } from "./ui/toast";...@@ -128,6 +130,12 @@ import { stackedThreadToast, toastManager } from "./ui/toast";
128import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";130import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
129import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";131import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
130import type { ChatComposerHandle } from "./chat/ChatComposer";132import type { ChatComposerHandle } from "./chat/ChatComposer";
133import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
134import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
135import {
136 buildSidebarProjectPickerEntries,
137 buildSidebarProjectSnapshots,
138} from "../sidebarProjectGrouping";
131139
132const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];140const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
133141
...@@ -487,10 +495,11 @@ function OpenCommandPaletteDialog(props: {...@@ -487,10 +495,11 @@ function OpenCommandPaletteDialog(props: {
487 });495 });
488 const { environments } = useEnvironments();496 const { environments } = useEnvironments();
489 const desktopLocalBootstraps = useDesktopLocalBootstraps();497 const desktopLocalBootstraps = useDesktopLocalBootstraps();
490 const primaryEnvironment = usePrimaryEnvironment();498 const primaryEnvironmentId = usePrimaryEnvironmentId();
491 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =499 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
492 useHandleNewThread();500 useHandleNewThread();
493 const projects = useProjects();501 const projects = useProjects();
502 const projectOrder = useUiStateStore((store) => store.projectOrder);
494 const threads = useThreadShells();503 const threads = useThreadShells();
495 const keybindings = useAtomValue(primaryServerKeybindingsAtom);504 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
496 const providers = useAtomValue(primaryServerProvidersAtom);505 const providers = useAtomValue(primaryServerProvidersAtom);
...@@ -504,7 +513,93 @@ function OpenCommandPaletteDialog(props: {...@@ -504,7 +513,93 @@ function OpenCommandPaletteDialog(props: {
504 const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null);513 const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null);
505 const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false);514 const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false);
506 const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false);515 const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false);
507 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;516 const projectGroupingSettings = useMemo(
517 () => selectProjectGroupingSettings(clientSettings),
518 [clientSettings],
519 );
520
521 const environmentLabelById = useMemo(
522 () =>
523 new Map(
524 environments.map((environment) => [environment.environmentId, environment.label] as const),
525 ),
526 [environments],
527 );
528 const orderedProjects = useMemo(
529 () =>
530 orderItemsByPreferredIds({
531 items: projects,
532 preferredIds: projectOrder,
533 getId: getProjectOrderKey,
534 getPreferenceIds: (project) => [
535 getProjectOrderKey(project),
536 legacyProjectCwdPreferenceKey(project.workspaceRoot),
537 ],
538 }),
539 [projectOrder, projects],
540 );
541 const unsortedProjectGroups = useMemo(
542 () =>
543 buildSidebarProjectSnapshots({
544 projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects,
545 settings: projectGroupingSettings,
546 primaryEnvironmentId,
547 resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
548 }),
549 [
550 clientSettings.sidebarProjectSortOrder,
551 environmentLabelById,
552 orderedProjects,
553 primaryEnvironmentId,
554 projectGroupingSettings,
555 projects,
556 ],
557 );
558 const projectGroups = useMemo(
559 () =>
560 sortLogicalProjectsForSidebar(
561 unsortedProjectGroups,
562 threads,
563 clientSettings.sidebarProjectSortOrder,
564 ),
565 [clientSettings.sidebarProjectSortOrder, threads, unsortedProjectGroups],
566 );
567 const contextualProjectRef = useMemo(
568 () =>
569 resolveThreadActionProjectRef({
570 activeDraftThread,
571 activeThread: activeThread ?? undefined,
572 defaultProjectRef,
573 handleNewThread,
574 }),
575 [activeDraftThread, activeThread, defaultProjectRef, handleNewThread],
576 );
577 const projectPickerEntries = useMemo(
578 () =>
579 buildSidebarProjectPickerEntries({
580 groups: projectGroups,
581 preferredProjectRef: contextualProjectRef,
582 }),
583 [contextualProjectRef, projectGroups],
584 );
585 const pickerProjects = useMemo(
586 () =>
587 projectPickerEntries.map(({ group, targetProject }) => ({
588 ...targetProject,
589 title: group.displayName,
590 })),
591 [projectPickerEntries],
592 );
593 const projectGroupByTargetKey = useMemo(
594 () =>
595 new Map(
596 projectPickerEntries.map(({ group, targetProject }) => [
597 `${targetProject.environmentId}:${targetProject.id}`,
598 group,
599 ]),
600 ),
601 [projectPickerEntries],
602 );
508603
509 const addProjectEnvironmentOptions = useMemo(() => {604 const addProjectEnvironmentOptions = useMemo(() => {
510 const options = environments.map((environment): AddProjectEnvironmentOption => {605 const options = environments.map((environment): AddProjectEnvironmentOption => {
...@@ -648,11 +743,28 @@ function OpenCommandPaletteDialog(props: {...@@ -648,11 +743,28 @@ function OpenCommandPaletteDialog(props: {
648743
649 const openProjectFromSearch = useMemo(744 const openProjectFromSearch = useMemo(
650 () => async (project: (typeof projects)[number]) => {745 () => async (project: (typeof projects)[number]) => {
651 const latestThread = getLatestThreadForProject(746 const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
652 threads.filter((thread) => thread.environmentId === project.environmentId),747 const groupedProjectKeys = group
653 project.id,748 ? new Set(
654 clientSettings.sidebarThreadSortOrder,749 group.memberProjectRefs.map(
655 );750 (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
751 ),
752 )
753 : null;
754 const latestThread = groupedProjectKeys
755 ? (sortThreads(
756 threads.filter(
757 (thread) =>
758 thread.archivedAt === null &&
759 groupedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`),
760 ),
761 clientSettings.sidebarThreadSortOrder,
762 )[0] ?? null)
763 : getLatestThreadForProject(
764 threads.filter((thread) => thread.environmentId === project.environmentId),
765 project.id,
766 clientSettings.sidebarThreadSortOrder,
767 );
656 if (latestThread) {768 if (latestThread) {
657 await navigate({769 await navigate({
658 to: "/$environmentId/$threadId",770 to: "/$environmentId/$threadId",
...@@ -665,14 +777,26 @@ function OpenCommandPaletteDialog(props: {...@@ -665,14 +777,26 @@ function OpenCommandPaletteDialog(props: {
665777
666 await handleNewThread(scopeProjectRef(project.environmentId, project.id));778 await handleNewThread(scopeProjectRef(project.environmentId, project.id));
667 },779 },
668 [handleNewThread, navigate, clientSettings.sidebarThreadSortOrder, threads],780 [
781 clientSettings.sidebarThreadSortOrder,
782 handleNewThread,
783 navigate,
784 projectGroupByTargetKey,
785 threads,
786 ],
669 );787 );
670788
671 const projectSearchItems = useMemo(789 const projectSearchItems = useMemo(
672 () =>790 () =>
673 buildProjectActionItems({791 buildProjectActionItems({
674 projects,792 projects: pickerProjects,
675 valuePrefix: "project",793 valuePrefix: "project",
794 searchTerms: (project) => {
795 const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
796 return (
797 group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? []
798 );
799 },
676 icon: (project) => (800 icon: (project) => (
677 <ProjectFavicon801 <ProjectFavicon
678 environmentId={project.environmentId}802 environmentId={project.environmentId}
...@@ -682,15 +806,21 @@ function OpenCommandPaletteDialog(props: {...@@ -682,15 +806,21 @@ function OpenCommandPaletteDialog(props: {
682 ),806 ),
683 runProject: openProjectFromSearch,807 runProject: openProjectFromSearch,
684 }),808 }),
685 [openProjectFromSearch, projects],809 [openProjectFromSearch, pickerProjects, projectGroupByTargetKey],
686 );810 );
687811
688 const projectThreadItems = useMemo(812 const projectThreadItems = useMemo(
689 () =>813 () =>
690 enumerateCommandPaletteItems(814 enumerateCommandPaletteItems(
691 buildProjectActionItems({815 buildProjectActionItems({
692 projects,816 projects: pickerProjects,
693 valuePrefix: "new-thread-in",817 valuePrefix: "new-thread-in",
818 searchTerms: (project) => {
819 const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
820 return (
821 group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? []
822 );
823 },
694 icon: (project) => (824 icon: (project) => (
695 <ProjectFavicon825 <ProjectFavicon
696 environmentId={project.environmentId}826 environmentId={project.environmentId}
...@@ -699,6 +829,14 @@ function OpenCommandPaletteDialog(props: {...@@ -699,6 +829,14 @@ function OpenCommandPaletteDialog(props: {
699 />829 />
700 ),830 ),
701 runProject: async (project) => {831 runProject: async (project) => {
832 const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
833 const contextualRefBelongsToGroup =
834 contextualProjectRef !== null &&
835 group?.memberProjectRefs.some(
836 (projectRef) =>
837 projectRef.environmentId === contextualProjectRef.environmentId &&
838 projectRef.projectId === contextualProjectRef.projectId,
839 );
702 await startNewThreadInProjectFromContext(840 await startNewThreadInProjectFromContext(
703 {841 {
704 activeDraftThread,842 activeDraftThread,
...@@ -706,12 +844,22 @@ function OpenCommandPaletteDialog(props: {...@@ -706,12 +844,22 @@ function OpenCommandPaletteDialog(props: {
706 defaultProjectRef,844 defaultProjectRef,
707 handleNewThread,845 handleNewThread,
708 },846 },
709 scopeProjectRef(project.environmentId, project.id),847 contextualRefBelongsToGroup
848 ? contextualProjectRef
849 : scopeProjectRef(project.environmentId, project.id),
710 );850 );
711 },851 },
712 }),852 }),
713 ),853 ),
714 [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects],854 [
855 activeDraftThread,
856 activeThread,
857 contextualProjectRef,
858 defaultProjectRef,
859 handleNewThread,
860 pickerProjects,
861 projectGroupByTargetKey,
862 ],
715 );863 );
716864
717 const allThreadItems = useMemo(865 const allThreadItems = useMemo(
...@@ -1018,9 +1166,9 @@ function OpenCommandPaletteDialog(props: {...@@ -1018,9 +1166,9 @@ function OpenCommandPaletteDialog(props: {
1018 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];1166 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];
10191167
1020 if (projects.length > 0) {1168 if (projects.length > 0) {
1021 const activeProjectTitle = currentProjectId1169 const activeProjectTitle =
1022 ? (projectTitleById.get(currentProjectId) ?? null)1170 projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ??
1023 : null;1171 (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null);
10241172
1025 if (activeProjectTitle) {1173 if (activeProjectTitle) {
1026 actionItems.push({1174 actionItems.push({
apps/web/src/components/SidebarV2.tsx+14-7
...@@ -598,7 +598,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -598,7 +598,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
598 type="button"598 type="button"
599 aria-label="Un-settle thread"599 aria-label="Un-settle thread"
600 onClick={handleUnsettleClick}600 onClick={handleUnsettleClick}
601 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"601 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"
602 >602 >
603 <Undo2Icon className="size-3" />603 <Undo2Icon className="size-3" />
604 </button>604 </button>
...@@ -607,7 +607,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -607,7 +607,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
607 type="button"607 type="button"
608 aria-label="Settle thread"608 aria-label="Settle thread"
609 onClick={handleSettleClick}609 onClick={handleSettleClick}
610 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"610 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"
611 >611 >
612 <CheckIcon className="size-3" />612 <CheckIcon className="size-3" />
613 </button>613 </button>
...@@ -684,7 +684,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -684,7 +684,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
684 type="button"684 type="button"
685 aria-label="Settle thread"685 aria-label="Settle thread"
686 onClick={handleSettleClick}686 onClick={handleSettleClick}
687 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"687 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"
688 >688 >
689 <CheckIcon className="size-3" />689 <CheckIcon className="size-3" />
690 Settle690 Settle
...@@ -874,10 +874,16 @@ export default function SidebarV2() {...@@ -874,10 +874,16 @@ export default function SidebarV2() {
874 ),874 ),
875 [projects],875 [projects],
876 );876 );
877 const projectTitleByKey = useMemo(877 const projectDisplayNameByKey = useMemo(
878 () =>878 () =>
879 new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])),879 new Map(
880 [projects],880 projectGroups.flatMap((group) =>
881 group.memberProjects.map(
882 (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const,
883 ),
884 ),
885 ),
886 [projectGroups],
881 );887 );
882888
883 // now is quantized to the minute so effectiveSettled memoization doesn't889 // now is quantized to the minute so effectiveSettled memoization doesn't
...@@ -1876,7 +1882,8 @@ export default function SidebarV2() {...@@ -1876,7 +1882,8 @@ export default function SidebarV2() {
1876 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null1882 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
1877 }1883 }
1878 projectTitle={1884 projectTitle={
1879 projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null1885 projectDisplayNameByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
1886 null
1880 }1887 }
1881 providerEntryByInstanceId={providerEntryByInstanceId}1888 providerEntryByInstanceId={providerEntryByInstanceId}
1882 onThreadClick={handleThreadClick}1889 onThreadClick={handleThreadClick}
apps/web/src/components/chat/DraftHeroHeadline.tsx+67-23
...@@ -5,8 +5,15 @@ import { useCallback, useMemo } from "react";...@@ -5,8 +5,15 @@ import { useCallback, useMemo } from "react";
55
6import { openCommandPalette } from "~/commandPaletteBus";6import { openCommandPalette } from "~/commandPaletteBus";
7import { useNewThreadHandler } from "~/hooks/useHandleNewThread";7import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
8import { useClientSettings } from "~/hooks/useSettings";
9import { selectProjectGroupingSettings } from "~/logicalProject";
10import {
11 buildSidebarProjectPickerEntries,
12 buildSidebarProjectSnapshots,
13} from "~/sidebarProjectGrouping";
8import { useProjects, useThreadShells } from "~/state/entities";14import { useProjects, useThreadShells } from "~/state/entities";
9import { sortScopedProjectsForSidebar } from "../Sidebar.logic";15import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
16import { sortLogicalProjectsForSidebar } from "../Sidebar.logic";
10import {17import {
11 Menu,18 Menu,
12 MenuItem,19 MenuItem,
...@@ -28,29 +35,66 @@ export function DraftHeroHeadline({...@@ -28,29 +35,66 @@ export function DraftHeroHeadline({
28}: DraftHeroHeadlineProps) {35}: DraftHeroHeadlineProps) {
29 const projects = useProjects();36 const projects = useProjects();
30 const threads = useThreadShells();37 const threads = useThreadShells();
38 const { environments } = useEnvironments();
39 const primaryEnvironmentId = usePrimaryEnvironmentId();
40 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
41 const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder);
31 const handleNewThread = useNewThreadHandler();42 const handleNewThread = useNewThreadHandler();
32 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);43 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
3344
34 const orderedProjects = useMemo(45 const environmentLabelById = useMemo(
35 () => sortScopedProjectsForSidebar(projects, threads, "updated_at"),
36 [projects, threads],
37 );
38 const projectByKey = useMemo(
39 () =>46 () =>
40 new Map(47 new Map(
41 orderedProjects.map(48 environments.map((environment) => [environment.environmentId, environment.label] as const),
42 (project) =>49 ),
43 [50 [environments],
44 scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),51 );
45 project,52 const projectGroups = useMemo(
46 ] as const,53 () =>
47 ),54 sortLogicalProjectsForSidebar(
55 buildSidebarProjectSnapshots({
56 projects,
57 settings: projectGroupingSettings,
58 primaryEnvironmentId,
59 resolveEnvironmentLabel: (environmentId) =>
60 environmentLabelById.get(environmentId) ?? null,
61 }),
62 threads,
63 projectSortOrder,
48 ),64 ),
49 [orderedProjects],65 [
66 environmentLabelById,
67 primaryEnvironmentId,
68 projectGroupingSettings,
69 projectSortOrder,
70 projects,
71 threads,
72 ],
73 );
74 const projectPickerEntries = useMemo(
75 () =>
76 buildSidebarProjectPickerEntries({
77 groups: projectGroups,
78 preferredProjectRef: activeProjectRef,
79 }),
80 [activeProjectRef, projectGroups],
81 );
82 const projectEntryByKey = useMemo(
83 () => new Map(projectPickerEntries.map((entry) => [entry.group.projectKey, entry] as const)),
84 [projectPickerEntries],
50 );85 );
51 const activeProjectKey = activeProjectRef === null ? "" : scopedProjectKey(activeProjectRef);86 const activeProjectGroup =
87 activeProjectRef === null
88 ? null
89 : (projectGroups.find((group) =>
90 group.memberProjectRefs.some(
91 (projectRef) => scopedProjectKey(projectRef) === scopedProjectKey(activeProjectRef),
92 ),
93 ) ?? null);
94 const activeProjectKey = activeProjectGroup?.projectKey ?? "";
95 const activeProjectDisplayName = activeProjectGroup?.displayName ?? activeProjectTitle;
52 const hasResolvedProject = activeProjectTitle !== null;96 const hasResolvedProject = activeProjectTitle !== null;
53 const canChooseProject = orderedProjects.length > 0;97 const canChooseProject = projectPickerEntries.length > 0;
54 const shouldShowProjectMenu = canChooseProject;98 const shouldShowProjectMenu = canChooseProject;
5599
56 const projectSelector = shouldShowProjectMenu ? (100 const projectSelector = shouldShowProjectMenu ? (
...@@ -59,26 +103,26 @@ export function DraftHeroHeadline({...@@ -59,26 +103,26 @@ export function DraftHeroHeadline({
59 aria-label={hasResolvedProject ? "Change project" : "Choose a project"}103 aria-label={hasResolvedProject ? "Change project" : "Choose a project"}
60 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"104 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"
61 >105 >
62 {activeProjectTitle ?? "Choose a project"}106 {activeProjectDisplayName ?? "Choose a project"}
63 </MenuTrigger>107 </MenuTrigger>
64 <MenuPopup align="center" className="max-h-80 w-64 overflow-y-auto">108 <MenuPopup align="center" className="max-h-80 w-64 overflow-y-auto">
65 <MenuRadioGroup109 <MenuRadioGroup
66 value={activeProjectKey}110 value={activeProjectKey}
67 onValueChange={(value) => {111 onValueChange={(value) => {
68 const project = projectByKey.get(value as string);112 const entry = projectEntryByKey.get(value as string);
69 if (!project || value === activeProjectKey) {113 if (!entry || value === activeProjectKey) {
70 return;114 return;
71 }115 }
116 const project = entry.targetProject;
72 void handleNewThread(scopeProjectRef(project.environmentId, project.id), {117 void handleNewThread(scopeProjectRef(project.environmentId, project.id), {
73 replace: true,118 replace: true,
74 });119 });
75 }}120 }}
76 >121 >
77 {orderedProjects.map((project) => {122 {projectPickerEntries.map(({ group }) => {
78 const key = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));
79 return (123 return (
80 <MenuRadioItem key={key} value={key} closeOnClick>124 <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>
81 <span className="min-w-0 truncate">{project.title}</span>125 <span className="min-w-0 truncate">{group.displayName}</span>
82 </MenuRadioItem>126 </MenuRadioItem>
83 );127 );
84 })}128 })}
apps/web/src/routes/_chat.tsx+19-4
...@@ -1,11 +1,14 @@...@@ -1,11 +1,14 @@
1import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";1import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import { useEffect } from "react";3import { useEffect, useMemo } from "react";
44
5import { isCommandPaletteOpen } from "../commandPaletteBus";5import { isCommandPaletteOpen } from "../commandPaletteBus";
6import { useClientSettings } from "../hooks/useSettings";6import { useClientSettings } from "../hooks/useSettings";
7import { openCommandPalette } from "../commandPaletteBus";7import { openCommandPalette } from "../commandPaletteBus";
8import { useProjects } from "../state/entities";8import { useProjects } from "../state/entities";
9import { usePrimaryEnvironmentId } from "../state/environments";
10import { selectProjectGroupingSettings } from "../logicalProject";
11import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
9import { dispatchPreviewAction } from "../components/preview/previewActionBus";12import { dispatchPreviewAction } from "../components/preview/previewActionBus";
10import { useHandleNewThread } from "../hooks/useHandleNewThread";13import { useHandleNewThread } from "../hooks/useHandleNewThread";
11import {14import {
...@@ -29,7 +32,19 @@ function ChatRouteGlobalShortcuts() {...@@ -29,7 +32,19 @@ function ChatRouteGlobalShortcuts() {
29 useHandleNewThread();32 useHandleNewThread();
30 const keybindings = useAtomValue(primaryServerKeybindingsAtom);33 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
31 const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);34 const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
32 const projectCount = useProjects().length;35 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
36 const projects = useProjects();
37 const primaryEnvironmentId = usePrimaryEnvironmentId();
38 const projectGroupCount = useMemo(
39 () =>
40 buildSidebarProjectSnapshots({
41 projects,
42 settings: projectGroupingSettings,
43 primaryEnvironmentId,
44 resolveEnvironmentLabel: () => null,
45 }).length,
46 [primaryEnvironmentId, projectGroupingSettings, projects],
47 );
33 const terminalOpen = useTerminalUiStateStore((state) =>48 const terminalOpen = useTerminalUiStateStore((state) =>
34 routeThreadRef49 routeThreadRef
35 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen50 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
...@@ -83,7 +98,7 @@ function ChatRouteGlobalShortcuts() {...@@ -83,7 +98,7 @@ function ChatRouteGlobalShortcuts() {
83 // Sidebar v2 routes creation through the command palette whenever98 // Sidebar v2 routes creation through the command palette whenever
84 // there is a real choice to make; v1 (and single-project setups)99 // there is a real choice to make; v1 (and single-project setups)
85 // keep the immediate contextual create.100 // keep the immediate contextual create.
86 if (sidebarV2Enabled && projectCount > 1) {101 if (sidebarV2Enabled && projectGroupCount > 1) {
87 openCommandPalette({ open: "new-thread-in" });102 openCommandPalette({ open: "new-thread-in" });
88 return;103 return;
89 }104 }
...@@ -152,7 +167,7 @@ function ChatRouteGlobalShortcuts() {...@@ -152,7 +167,7 @@ function ChatRouteGlobalShortcuts() {
152 keybindings,167 keybindings,
153 defaultProjectRef,168 defaultProjectRef,
154 previewOpen,169 previewOpen,
155 projectCount,170 projectGroupCount,
156 routeThreadRef,171 routeThreadRef,
157 selectedThreadKeysSize,172 selectedThreadKeysSize,
158 sidebarV2Enabled,173 sidebarV2Enabled,