| @@ -1,10 +1,7 @@ |
| 1 | 1 | import { autoAnimate } from "@formkit/auto-animate"; |
| 2 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 3 | import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; |
| 4 | | −import type { |
| 5 | | − EnvironmentProject, |
| 6 | | − EnvironmentThreadShell, |
| 7 | | −} from "@t3tools/client-runtime/state/models"; |
| 4 | +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; |
| 8 | 5 | import { |
| 9 | 6 | scopeProjectRef, |
| 10 | 7 | scopeThreadRef, |
| @@ -61,7 +58,12 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina · 50 unmodified lines |
| 61 | 58 | import { isMacPlatform } from "~/lib/utils"; |
| 62 | 59 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 63 | 60 | import { readLocalApi } from "../localApi"; |
| 64 | | −import { useUiStateStore } from "../uiStateStore"; |
| 61 | +import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; |
| 62 | +import { |
| 63 | + buildSidebarProjectSnapshots, |
| 64 | + type SidebarProjectSnapshot, |
| 65 | +} from "../sidebarProjectGrouping"; |
| 66 | +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| 65 | 67 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 66 | 68 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 67 | 69 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| @@ -84,9 +86,11 @@ import { · 16 unmodified lines |
| 84 | 86 | firstValidTimestampMs, |
| 85 | 87 | hasUnseenCompletion, |
| 86 | 88 | isTrailingDoubleClick, |
| 89 | + orderItemsByPreferredIds, |
| 87 | 90 | resolveAdjacentThreadId, |
| 88 | 91 | resolveSidebarV2Status, |
| 89 | 92 | shouldNavigateAfterProjectRemoval, |
| 93 | + sortLogicalProjectsForSidebar, |
| 90 | 94 | sortThreadsForSidebarV2, |
| 91 | 95 | } from "./Sidebar.logic"; |
| 92 | 96 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| @@ -716,12 +720,15 @@ function latestTurnDiff( · 623 unmodified lines |
| 716 | 720 | |
| 717 | 721 | export default function SidebarV2() { |
| 718 | 722 | const projects = useProjects(); |
| 723 | + const projectOrder = useUiStateStore((store) => store.projectOrder); |
| 719 | 724 | const threads = useThreadShells(); |
| 720 | 725 | const router = useRouter(); |
| 721 | 726 | const { isMobile, setOpenMobile } = useSidebar(); |
| 722 | 727 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 723 | 728 | const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); |
| 724 | 729 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 730 | + const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 731 | + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 725 | 732 | const { settleThread, unsettleThread, deleteThread } = useThreadActions(); |
| 726 | 733 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { |
| 727 | 734 | reportFailure: false, |
| @@ -762,6 +769,40 @@ export default function SidebarV2() { · 34 unmodified lines |
| 762 | 769 | ), |
| 763 | 770 | [environments], |
| 764 | 771 | ); |
| 772 | + const orderedProjects = useMemo( |
| 773 | + () => |
| 774 | + orderItemsByPreferredIds({ |
| 775 | + items: projects, |
| 776 | + preferredIds: projectOrder, |
| 777 | + getId: getProjectOrderKey, |
| 778 | + getPreferenceIds: (project) => [ |
| 779 | + getProjectOrderKey(project), |
| 780 | + legacyProjectCwdPreferenceKey(project.workspaceRoot), |
| 781 | + ], |
| 782 | + }), |
| 783 | + [projectOrder, projects], |
| 784 | + ); |
| 785 | + const unsortedProjectGroups = useMemo( |
| 786 | + () => |
| 787 | + buildSidebarProjectSnapshots({ |
| 788 | + projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, |
| 789 | + settings: projectGroupingSettings, |
| 790 | + primaryEnvironmentId, |
| 791 | + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, |
| 792 | + }), |
| 793 | + [ |
| 794 | + environmentLabelById, |
| 795 | + orderedProjects, |
| 796 | + primaryEnvironmentId, |
| 797 | + projectGroupingSettings, |
| 798 | + projects, |
| 799 | + sidebarProjectSortOrder, |
| 800 | + ], |
| 801 | + ); |
| 802 | + const projectGroups = useMemo( |
| 803 | + () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), |
| 804 | + [sidebarProjectSortOrder, threads, unsortedProjectGroups], |
| 805 | + ); |
| 765 | 806 | const serverProviders = useAtomValue(primaryServerProvidersAtom); |
| 766 | 807 | const providerEntryByInstanceId = useMemo( |
| 767 | 808 | () => |
| @@ -823,23 +864,29 @@ export default function SidebarV2() { · 55 unmodified lines |
| 823 | 864 | // Project scope: one menu above the list. Scoping filters the list without |
| 824 | 865 | // making the header width depend on the number or length of project names. |
| 825 | 866 | const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null); |
| 826 | | − const scopedProject = useMemo( |
| 867 | + const scopedProjectGroup = useMemo( |
| 827 | 868 | () => |
| 828 | 869 | projectScopeKey === null |
| 829 | 870 | ? null |
| 830 | | − : (projects.find( |
| 831 | | − (project) => `${project.environmentId}:${project.id}` === projectScopeKey, |
| 832 | | − ) ?? null), |
| 833 | | − [projectScopeKey, projects], |
| 871 | + : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null), |
| 872 | + [projectGroups, projectScopeKey], |
| 873 | + ); |
| 874 | + const scopedProjectKeys = useMemo( |
| 875 | + () => |
| 876 | + scopedProjectGroup === null |
| 877 | + ? null |
| 878 | + : new Set( |
| 879 | + scopedProjectGroup.memberProjectRefs.map( |
| 880 | + (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, |
| 881 | + ), |
| 882 | + ), |
| 883 | + [scopedProjectGroup], |
| 834 | 884 | ); |
| 835 | 885 | useEffect(() => { |
| 836 | | − if ( |
| 837 | | − projectScopeKey !== null && |
| 838 | | − !projects.some((project) => `${project.environmentId}:${project.id}` === projectScopeKey) |
| 839 | | − ) { |
| 886 | + if (projectScopeKey !== null && scopedProjectGroup === null) { |
| 840 | 887 | setProjectScopeKey(null); |
| 841 | 888 | } |
| 842 | | − }, [projectScopeKey, projects]); |
| 889 | + }, [projectScopeKey, scopedProjectGroup]); |
| 843 | 890 | // Scope flips drop the selection: rows selected under the old scope may be |
| 844 | 891 | // hidden now, and bulk actions must never count or touch invisible rows. |
| 845 | 892 | useEffect(() => { |
| @@ -847,66 +894,90 @@ export default function SidebarV2() { · 1 unmodified line |
| 847 | 894 | }, [clearSelection, projectScopeKey]); |
| 848 | 895 | |
| 849 | 896 | const handleRemoveProject = useCallback( |
| 850 | | − async (project: EnvironmentProject) => { |
| 897 | + async (projectGroup: SidebarProjectSnapshot) => { |
| 851 | 898 | const api = readLocalApi(); |
| 852 | 899 | if (!api) return; |
| 853 | 900 | |
| 854 | | − const projectThreads = threads.filter( |
| 855 | | − (thread) => |
| 856 | | − thread.environmentId === project.environmentId && thread.projectId === project.id, |
| 901 | + const memberKeys = new Set( |
| 902 | + projectGroup.memberProjectRefs.map( |
| 903 | + (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, |
| 904 | + ), |
| 905 | + ); |
| 906 | + const projectThreads = threads.filter((thread) => |
| 907 | + memberKeys.has(`${thread.environmentId}:${thread.projectId}`), |
| 857 | 908 | ); |
| 858 | 909 | const confirmed = await settlePromise(() => |
| 859 | 910 | api.dialogs.confirm( |
| 860 | 911 | projectThreads.length > 0 |
| 861 | 912 | ? [ |
| 862 | | − `Remove project "${project.title}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, |
| 863 | | − `Path: ${project.workspaceRoot}`, |
| 913 | + `Remove project "${projectGroup.displayName}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, |
| 914 | + ...(projectGroup.memberProjects.length === 1 |
| 915 | + ? [`Path: ${projectGroup.workspaceRoot}`] |
| 916 | + : [ |
| 917 | + `This removes ${projectGroup.memberProjects.length} grouped project entries.`, |
| 918 | + ]), |
| 864 | 919 | "This permanently clears conversation history for those threads.", |
| 865 | | − "This removes only the project entry, not the files on disk.", |
| 920 | + "This removes only the project entries, not the files on disk.", |
| 866 | 921 | "This action cannot be undone.", |
| 867 | 922 | ].join("\n") |
| 868 | 923 | : [ |
| 869 | | − `Remove project "${project.title}"?`, |
| 870 | | − `Path: ${project.workspaceRoot}`, |
| 871 | | − "This removes only the project entry, not the files on disk.", |
| 924 | + `Remove project "${projectGroup.displayName}"?`, |
| 925 | + ...(projectGroup.memberProjects.length === 1 |
| 926 | + ? [`Path: ${projectGroup.workspaceRoot}`] |
| 927 | + : [ |
| 928 | + `This removes ${projectGroup.memberProjects.length} grouped project entries.`, |
| 929 | + ]), |
| 930 | + "This removes only the project entries, not the files on disk.", |
| 872 | 931 | ].join("\n"), |
| 873 | 932 | ), |
| 874 | 933 | ); |
| 875 | 934 | if (confirmed._tag === "Failure" || !confirmed.value) return; |
| 876 | 935 | |
| 877 | | − const projectRef = scopeProjectRef(project.environmentId, project.id); |
| 878 | | − const result = await deleteProject({ |
| 879 | | − environmentId: project.environmentId, |
| 880 | | − input: { |
| 881 | | − projectId: project.id, |
| 882 | | − ...(projectThreads.length > 0 ? { force: true } : {}), |
| 883 | | − }, |
| 884 | | − }); |
| 885 | | − if (result._tag === "Failure") { |
| 886 | | − if (!isAtomCommandInterrupted(result)) { |
| 887 | | − const error = squashAtomCommandFailure(result); |
| 888 | | − toastManager.add( |
| 889 | | − stackedThreadToast({ |
| 890 | | − type: "error", |
| 891 | | − title: `Failed to remove "${project.title}"`, |
| 892 | | − description: error instanceof Error ? error.message : "An error occurred.", |
| 893 | | − }), |
| 894 | | − ); |
| 936 | + const draftStore = useComposerDraftStore.getState(); |
| 937 | + let shouldNavigate = false; |
| 938 | + for (const project of projectGroup.memberProjects) { |
| 939 | + const memberThreads = projectThreads.filter( |
| 940 | + (thread) => |
| 941 | + thread.environmentId === project.environmentId && thread.projectId === project.id, |
| 942 | + ); |
| 943 | + const projectRef = scopeProjectRef(project.environmentId, project.id); |
| 944 | + const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); |
| 945 | + const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({ |
| 946 | + routeTarget: routeTargetRef.current, |
| 947 | + projectThreads: memberThreads, |
| 948 | + projectDraftId: projectDraftThread?.draftId ?? null, |
| 949 | + }); |
| 950 | + |
| 951 | + const result = await deleteProject({ |
| 952 | + environmentId: project.environmentId, |
| 953 | + input: { |
| 954 | + projectId: project.id, |
| 955 | + ...(memberThreads.length > 0 ? { force: true } : {}), |
| 956 | + }, |
| 957 | + }); |
| 958 | + if (result._tag === "Failure") { |
| 959 | + if (!isAtomCommandInterrupted(result)) { |
| 960 | + const error = squashAtomCommandFailure(result); |
| 961 | + toastManager.add( |
| 962 | + stackedThreadToast({ |
| 963 | + type: "error", |
| 964 | + title: `Failed to remove "${project.title}"`, |
| 965 | + description: error instanceof Error ? error.message : "An error occurred.", |
| 966 | + }), |
| 967 | + ); |
| 968 | + } |
| 969 | + if (shouldNavigate) { |
| 970 | + void router.navigate({ to: "/" }); |
| 971 | + } |
| 972 | + return; |
| 895 | 973 | } |
| 896 | | − return; |
| 897 | | − } |
| 898 | 974 | |
| 899 | | − const draftStore = useComposerDraftStore.getState(); |
| 900 | | − const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); |
| 901 | | − const shouldNavigate = shouldNavigateAfterProjectRemoval({ |
| 902 | | − routeTarget: routeTargetRef.current, |
| 903 | | − projectThreads, |
| 904 | | − projectDraftId: projectDraftThread?.draftId ?? null, |
| 905 | | − }); |
| 906 | | − if (projectDraftThread) { |
| 907 | | − draftStore.clearDraftThread(projectDraftThread.draftId); |
| 975 | + shouldNavigate ||= memberRemovalNeedsNavigation; |
| 976 | + if (projectDraftThread) { |
| 977 | + draftStore.clearDraftThread(projectDraftThread.draftId); |
| 978 | + } |
| 979 | + draftStore.clearProjectDraftThreadId(projectRef); |
| 908 | 980 | } |
| 909 | | − draftStore.clearProjectDraftThreadId(projectRef); |
| 910 | 981 | |
| 911 | 982 | if (shouldNavigate) { |
| 912 | 983 | void router.navigate({ to: "/" }); |
| @@ -925,9 +996,8 @@ export default function SidebarV2() { · 12 unmodified lines |
| 925 | 996 | const visible = threads.filter( |
| 926 | 997 | (thread) => |
| 927 | 998 | thread.archivedAt === null && |
| 928 | | − (scopedProject === null || |
| 929 | | − (thread.environmentId === scopedProject.environmentId && |
| 930 | | − thread.projectId === scopedProject.id)), |
| 999 | + (scopedProjectKeys === null || |
| 1000 | + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), |
| 931 | 1001 | ); |
| 932 | 1002 | const active: EnvironmentThreadShell[] = []; |
| 933 | 1003 | const settled: EnvironmentThreadShell[] = []; |
| @@ -961,7 +1031,7 @@ export default function SidebarV2() { · 27 unmodified lines |
| 961 | 1031 | autoSettleAfterDays, |
| 962 | 1032 | changeRequestStateByKey, |
| 963 | 1033 | nowMinute, |
| 964 | | − scopedProject, |
| 1034 | + scopedProjectKeys, |
| 965 | 1035 | serverConfigs, |
| 966 | 1036 | threads, |
| 967 | 1037 | ]); |
| @@ -1479,7 +1549,7 @@ export default function SidebarV2() { · 511 unmodified lines |
| 1479 | 1549 | // for multi-project setups. |
| 1480 | 1550 | const handleNewThreadClick = useCallback(() => { |
| 1481 | 1551 | // One project: nothing to pick, create immediately. |
| 1482 | | − if (projects.length <= 1) { |
| 1552 | + if (projectGroups.length <= 1) { |
| 1483 | 1553 | if (isMobile) setOpenMobile(false); |
| 1484 | 1554 | void startNewThreadFromContext({ |
| 1485 | 1555 | activeDraftThread: newThreadContext.activeDraftThread, |
| @@ -1491,7 +1561,7 @@ export default function SidebarV2() { · 5 unmodified lines |
| 1491 | 1561 | } |
| 1492 | 1562 | if (isMobile) setOpenMobile(false); |
| 1493 | 1563 | openCommandPalette({ open: "new-thread-in" }); |
| 1494 | | − }, [isMobile, newThreadContext, projects.length, setOpenMobile]); |
| 1564 | + }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); |
| 1495 | 1565 | |
| 1496 | 1566 | const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); |
| 1497 | 1567 | // Same resolution as v1: prefer the local-thread binding, fall back to |
| @@ -1553,7 +1623,7 @@ export default function SidebarV2() { · 55 unmodified lines |
| 1553 | 1623 | </div> |
| 1554 | 1624 | </div> |
| 1555 | 1625 | </SidebarGroup> |
| 1556 | | − {projects.length > 0 ? ( |
| 1626 | + {projectGroups.length > 0 ? ( |
| 1557 | 1627 | <SidebarGroup className="px-2 pb-2 pt-0"> |
| 1558 | 1628 | <div className="flex items-center gap-1"> |
| 1559 | 1629 | <Menu> |
| @@ -1561,17 +1631,17 @@ export default function SidebarV2() { · 1 unmodified line |
| 1561 | 1631 | aria-label="Filter threads by project" |
| 1562 | 1632 | className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 text-left text-sm font-medium text-sidebar-muted-foreground outline-none hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar" |
| 1563 | 1633 | > |
| 1564 | | − {scopedProject ? ( |
| 1634 | + {scopedProjectGroup ? ( |
| 1565 | 1635 | <ProjectFavicon |
| 1566 | | − environmentId={scopedProject.environmentId} |
| 1567 | | − cwd={scopedProject.workspaceRoot} |
| 1636 | + environmentId={scopedProjectGroup.environmentId} |
| 1637 | + cwd={scopedProjectGroup.workspaceRoot} |
| 1568 | 1638 | className="size-4 shrink-0" |
| 1569 | 1639 | /> |
| 1570 | 1640 | ) : ( |
| 1571 | 1641 | <FolderIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" /> |
| 1572 | 1642 | )} |
| 1573 | 1643 | <span className="min-w-0 flex-1 truncate"> |
| 1574 | | − {scopedProject?.title ?? "All projects"} |
| 1644 | + {scopedProjectGroup?.displayName ?? "All projects"} |
| 1575 | 1645 | </span> |
| 1576 | 1646 | <ChevronDownIcon className="size-4 shrink-0 text-sidebar-muted-foreground/70" /> |
| 1577 | 1647 | </MenuTrigger> |
| @@ -1590,8 +1660,8 @@ export default function SidebarV2() { · 12 unmodified lines |
| 1590 | 1660 | <FolderIcon className="size-4 shrink-0" /> |
| 1591 | 1661 | <span className="min-w-0 truncate text-sm">All projects</span> |
| 1592 | 1662 | </MenuRadioItem> |
| 1593 | | − {projects.map((project) => { |
| 1594 | | − const scopeKey = `${project.environmentId}:${project.id}`; |
| 1663 | + {projectGroups.map((project) => { |
| 1664 | + const scopeKey = project.projectKey; |
| 1595 | 1665 | return ( |
| 1596 | 1666 | <MenuRadioItem |
| 1597 | 1667 | key={scopeKey} |
| @@ -1604,11 +1674,11 @@ export default function SidebarV2() { · 6 unmodified lines |
| 1604 | 1674 | cwd={project.workspaceRoot} |
| 1605 | 1675 | className="size-4 shrink-0" |
| 1606 | 1676 | /> |
| 1607 | | − <span className="min-w-0 truncate text-sm">{project.title}</span> |
| 1677 | + <span className="min-w-0 truncate text-sm">{project.displayName}</span> |
| 1608 | 1678 | <button |
| 1609 | 1679 | type="button" |
| 1610 | | − aria-label={`Remove project ${project.title}`} |
| 1611 | | − title={`Remove ${project.title}`} |
| 1680 | + aria-label={`Remove project ${project.displayName}`} |
| 1681 | + title={`Remove ${project.displayName}`} |
| 1612 | 1682 | className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/55 outline-none transition-colors hover:bg-destructive/12 hover:text-destructive focus-visible:bg-destructive/12 focus-visible:text-destructive focus-visible:ring-2 focus-visible:ring-destructive/40" |
| 1613 | 1683 | onPointerDown={(event) => event.stopPropagation()} |
| 1614 | 1684 | onClick={(event) => { |
| @@ -1766,8 +1836,8 @@ export default function SidebarV2() { · 151 unmodified lines |
| 1766 | 1836 | Add project |
| 1767 | 1837 | </button> |
| 1768 | 1838 | </> |
| 1769 | | − ) : scopedProject ? ( |
| 1770 | | − `No threads in ${scopedProject.title} yet` |
| 1839 | + ) : scopedProjectGroup ? ( |
| 1840 | + `No threads in ${scopedProjectGroup.displayName} yet` |
| 1771 | 1841 | ) : ( |
| 1772 | 1842 | "No threads yet" |
| 1773 | 1843 | )} |