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

[codex] restore Sidebar V2 project actions (#4373)

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

2 files changed, 314 insertions(+), 33 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+1
......@@ -35,6 +35,7 @@ import {
3535 ProviderInstanceId,
3636 ThreadId,
3737} from "@t3tools/contracts";
38
3839import {
3940 DEFAULT_INTERACTION_MODE,
4041 DEFAULT_RUNTIME_MODE,
apps/web/src/components/SidebarV2.tsx+313-33
......@@ -7,16 +7,18 @@ import {
77 scopeThreadRef,
88 scopedThreadKey,
99} from "@t3tools/client-runtime/environment";
10import type { ScopedThreadRef } from "@t3tools/contracts";
10import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
1111import {
1212 CheckIcon,
1313 ChevronDownIcon,
1414 CircleAlertIcon,
1515 CircleCheckIcon,
1616 CircleDashedIcon,
17 CopyIcon,
1718 FolderIcon,
1819 FolderPlusIcon,
1920 GitBranchIcon,
21 EllipsisIcon,
2022 MessageSquareIcon,
2123 PlusIcon,
2224 SearchIcon,
......@@ -58,9 +60,14 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina
5860import { isMacPlatform } from "~/lib/utils";
5961import { useOpenPrLink } from "../lib/openPullRequestLink";
6062import { readLocalApi } from "../localApi";
61import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
63import {
64 deriveProjectGroupingOverrideKey,
65 getProjectOrderKey,
66 selectProjectGroupingSettings,
67} from "../logicalProject";
6268import {
6369 buildSidebarProjectSnapshots,
70 type SidebarProjectGroupMember,
6471 type SidebarProjectSnapshot,
6572} from "../sidebarProjectGrouping";
6673import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
......@@ -69,7 +76,8 @@ import { useThreadActions } from "../hooks/useThreadActions";
6976import { useHandleNewThread } from "../hooks/useHandleNewThread";
7077import { openCommandPalette } from "../commandPaletteBus";
7178import { startNewThreadFromContext } from "../lib/chatThreadActions";
72import { useClientSettings } from "../hooks/useSettings";
79import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";
80import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
7381import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
7482import { useProjects, useThreadShells } from "../state/entities";
7583import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";
......@@ -102,8 +110,20 @@ import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../pr
102110import { primaryServerProvidersAtom } from "../state/server";
103111import { stackedThreadToast, toastManager } from "./ui/toast";
104112import { CommandDialogTrigger } from "./ui/command";
113import { Button } from "./ui/button";
114import {
115 Dialog,
116 DialogDescription,
117 DialogFooter,
118 DialogHeader,
119 DialogPanel,
120 DialogPopup,
121 DialogTitle,
122} from "./ui/dialog";
123import { Input } from "./ui/input";
105124import { Kbd } from "./ui/kbd";
106125import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
126import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
107127import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
108128import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
109129import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
......@@ -113,6 +133,11 @@ import { useComposerDraftStore } from "../composerDraftStore";
113133// stays behind an explicit Show more.
114134const SETTLED_TAIL_INITIAL_COUNT = 10;
115135const SETTLED_TAIL_PAGE_COUNT = 25;
136const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
137 repository: "Group by repository",
138 repository_path: "Group by repository path",
139 separate: "Keep separate",
140};
116141
117142function compactSidebarTimeLabel(label: string): string {
118143 if (label === "just now") return "now";
......@@ -736,6 +761,32 @@ export default function SidebarV2() {
736761 const deleteProject = useAtomCommand(projectEnvironment.delete, {
737762 reportFailure: false,
738763 });
764 const updateProject = useAtomCommand(projectEnvironment.update, {
765 reportFailure: false,
766 });
767 const updateSettings = useUpdateClientSettings();
768 const { copyToClipboard: copyProjectPath } = useCopyToClipboard<{ path: string }>({
769 onCopy: ({ path }) => {
770 toastManager.add({
771 type: "success",
772 title: "Path copied",
773 description: path,
774 });
775 },
776 onError: (error) => {
777 toastManager.add(
778 stackedThreadToast({
779 type: "error",
780 title: "Failed to copy path",
781 description: error instanceof Error ? error.message : "An error occurred.",
782 }),
783 );
784 },
785 });
786 const [projectActionsTarget, setProjectActionsTarget] = useState<SidebarProjectSnapshot | null>(
787 null,
788 );
789 const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false);
739790 const newThreadContext = useHandleNewThread();
740791 const openAddProjectCommandPalette = useCallback(
741792 () => openCommandPalette({ open: "add-project" }),
......@@ -893,41 +944,50 @@ export default function SidebarV2() {
893944 clearSelection();
894945 }, [clearSelection, projectScopeKey]);
895946
896 const handleRemoveProject = useCallback(
897 async (projectGroup: SidebarProjectSnapshot) => {
947 const handleRemoveProjectMembers = useCallback(
948 async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => {
898949 const api = readLocalApi();
899950 if (!api) return;
900951
901 const memberKeys = new Set(
902 projectGroup.memberProjectRefs.map(
903 (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
904 ),
905 );
952 const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`));
906953 const projectThreads = threads.filter((thread) =>
907954 memberKeys.has(`${thread.environmentId}:${thread.projectId}`),
908955 );
956 const isWholeGroup = members.length === projectGroup.memberProjects.length;
957 const singleMember = members.length === 1 ? members[0]! : null;
958 const targetLabel = singleMember?.title ?? projectGroup.displayName;
909959 const confirmed = await settlePromise(() =>
910960 api.dialogs.confirm(
911961 projectThreads.length > 0
912962 ? [
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 ]),
963 `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`,
964 ...(singleMember
965 ? [
966 `Path: ${singleMember.workspaceRoot}`,
967 ...(singleMember.environmentLabel
968 ? [`Environment: ${singleMember.environmentLabel}`]
969 : []),
970 ]
971 : [`This removes ${members.length} grouped project entries.`]),
919972 "This permanently clears conversation history for those threads.",
920 "This removes only the project entries, not the files on disk.",
973 isWholeGroup
974 ? "This removes only the project entries, not the files on disk."
975 : "Other entries in this grouped project are unaffected.",
921976 "This action cannot be undone.",
922977 ].join("\n")
923978 : [
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.",
979 `Remove project "${targetLabel}"?`,
980 ...(singleMember
981 ? [
982 `Path: ${singleMember.workspaceRoot}`,
983 ...(singleMember.environmentLabel
984 ? [`Environment: ${singleMember.environmentLabel}`]
985 : []),
986 ]
987 : [`This removes ${members.length} grouped project entries.`]),
988 isWholeGroup
989 ? "This removes only the project entries, not the files on disk."
990 : "Other entries in this grouped project are unaffected.",
931991 ].join("\n"),
932992 ),
933993 );
......@@ -935,7 +995,7 @@ export default function SidebarV2() {
935995
936996 const draftStore = useComposerDraftStore.getState();
937997 let shouldNavigate = false;
938 for (const project of projectGroup.memberProjects) {
998 for (const project of members) {
939999 const memberThreads = projectThreads.filter(
9401000 (thread) =>
9411001 thread.environmentId === project.environmentId && thread.projectId === project.id,
......@@ -986,6 +1046,56 @@ export default function SidebarV2() {
9861046 [deleteProject, router, threads],
9871047 );
9881048
1049 const renameProjectMember = useCallback(
1050 async (member: SidebarProjectGroupMember, nextTitle: string) => {
1051 const title = nextTitle.trim();
1052 if (!title) {
1053 toastManager.add({ type: "warning", title: "Project title cannot be empty" });
1054 return;
1055 }
1056 if (title === member.title) return;
1057 const result = await updateProject({
1058 environmentId: member.environmentId,
1059 input: { projectId: member.id, title },
1060 });
1061 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1062 const error = squashAtomCommandFailure(result);
1063 toastManager.add(
1064 stackedThreadToast({
1065 type: "error",
1066 title: "Failed to rename project",
1067 description: error instanceof Error ? error.message : "An error occurred.",
1068 }),
1069 );
1070 }
1071 },
1072 [updateProject],
1073 );
1074
1075 const updateProjectGroupingPreference = useCallback(
1076 (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => {
1077 const overrideKey = deriveProjectGroupingOverrideKey(member);
1078 const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides };
1079 if (selection === "inherit") {
1080 delete nextOverrides[overrideKey];
1081 } else {
1082 nextOverrides[overrideKey] = selection;
1083 }
1084 updateSettings({ sidebarProjectGroupingOverrides: nextOverrides });
1085 },
1086 [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings],
1087 );
1088
1089 const handleProjectActions = useCallback(
1090 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {
1091 event.preventDefault();
1092 event.stopPropagation();
1093 setProjectScopeMenuOpen(false);
1094 window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup));
1095 },
1096 [],
1097 );
1098
9891099 // Settled threads stay in the live shell stream (settled ≠ archived), so
9901100 // the partition works directly off live shells: no archived-snapshot
9911101 // merging, no optimistic holds. Archived threads remain hidden here —
......@@ -1626,7 +1736,7 @@ export default function SidebarV2() {
16261736 {projectGroups.length > 0 ? (
16271737 <SidebarGroup className="px-2 pb-2 pt-0">
16281738 <div className="flex items-center gap-1">
1629 <Menu>
1739 <Menu open={projectScopeMenuOpen} onOpenChange={setProjectScopeMenuOpen}>
16301740 <MenuTrigger
16311741 aria-label="Filter threads by project"
16321742 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"
......@@ -1677,17 +1787,15 @@ export default function SidebarV2() {
16771787 <span className="min-w-0 truncate text-sm">{project.displayName}</span>
16781788 <button
16791789 type="button"
1680 aria-label={`Remove project ${project.displayName}`}
1681 title={`Remove ${project.displayName}`}
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"
1790 aria-label={`Project actions for ${project.displayName}`}
1791 title={`Project actions for ${project.displayName}`}
1792 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-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
16831793 onPointerDown={(event) => event.stopPropagation()}
16841794 onClick={(event) => {
1685 event.preventDefault();
1686 event.stopPropagation();
1687 void handleRemoveProject(project);
1795 void handleProjectActions(event, project);
16881796 }}
16891797 >
1690 <Trash2Icon className="size-3.5" />
1798 <EllipsisIcon className="size-3.5" />
16911799 </button>
16921800 </MenuRadioItem>
16931801 );
......@@ -1845,6 +1953,178 @@ export default function SidebarV2() {
18451953 ) : null}
18461954 </SidebarGroup>
18471955 </SidebarContent>
1956 <Dialog
1957 open={projectActionsTarget !== null}
1958 onOpenChange={(open) => {
1959 if (!open) setProjectActionsTarget(null);
1960 }}
1961 >
1962 <DialogPopup className="max-w-xl">
1963 <DialogHeader className="gap-1.5">
1964 <DialogTitle className="text-balance">Project settings</DialogTitle>
1965 <DialogDescription>
1966 {projectActionsTarget && projectActionsTarget.memberProjects.length > 1
1967 ? `${projectActionsTarget.displayName} has an entry in each environment. Changes apply only to the entry you choose.`
1968 : `Manage ${projectActionsTarget?.displayName ?? "this project"} in this environment.`}
1969 </DialogDescription>
1970 </DialogHeader>
1971 <DialogPanel className="p-0">
1972 <div className="divide-y divide-border/60">
1973 {projectActionsTarget?.memberProjects.map((member) => (
1974 <section
1975 key={member.physicalProjectKey}
1976 className="flex min-w-0 flex-col gap-4 px-6 py-5 sm:gap-3 sm:py-4"
1977 >
1978 <div className="flex min-w-0 items-start gap-3">
1979 <ProjectFavicon
1980 environmentId={member.environmentId}
1981 cwd={member.workspaceRoot}
1982 className="size-5 shrink-0 sm:size-4"
1983 />
1984 <div className="min-w-0 flex-1">
1985 <div className="flex min-w-0 items-center gap-1.5 text-base text-muted-foreground sm:text-sm">
1986 <ServerIcon className="size-4 shrink-0 stroke-muted-foreground" />
1987 <p className="min-w-0 truncate">
1988 {member.environmentLabel ?? "Current environment"}
1989 </p>
1990 </div>
1991 <p
1992 className="truncate font-mono text-base text-muted-foreground/72 sm:text-sm"
1993 title={member.workspaceRoot}
1994 >
1995 {member.workspaceRoot}
1996 </p>
1997 </div>
1998 </div>
1999 <div className="grid gap-4 sm:grid-cols-2 sm:gap-3 sm:pl-7">
2000 <label className="grid min-w-0 gap-1.5">
2001 <span className="font-medium text-foreground">Project name</span>
2002 <Input
2003 key={`${member.physicalProjectKey}:${member.title}`}
2004 size="sm"
2005 aria-label={`Project name in ${member.environmentLabel ?? "current environment"}`}
2006 defaultValue={member.title}
2007 onBlur={(event) => {
2008 void renameProjectMember(member, event.currentTarget.value);
2009 }}
2010 onKeyDown={(event) => {
2011 if (event.key === "Enter") event.currentTarget.blur();
2012 }}
2013 />
2014 </label>
2015 <label className="grid min-w-0 gap-1.5">
2016 <span className="font-medium text-foreground">Grouping rule</span>
2017 <Select
2018 value={
2019 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
2020 deriveProjectGroupingOverrideKey(member)
2021 ] ?? "inherit"
2022 }
2023 onValueChange={(value) => {
2024 if (
2025 value === "inherit" ||
2026 value === "repository" ||
2027 value === "repository_path" ||
2028 value === "separate"
2029 ) {
2030 updateProjectGroupingPreference(member, value);
2031 }
2032 }}
2033 >
2034 <SelectTrigger
2035 size="sm"
2036 className="w-full"
2037 aria-label={`Grouping rule for ${member.environmentLabel ?? "current environment"}`}
2038 >
2039 <SelectValue>
2040 {(() => {
2041 const selection =
2042 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
2043 deriveProjectGroupingOverrideKey(member)
2044 ] ?? "inherit";
2045 return selection === "inherit"
2046 ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})`
2047 : PROJECT_GROUPING_MODE_LABELS[selection];
2048 })()}
2049 </SelectValue>
2050 </SelectTrigger>
2051 <SelectPopup align="start" alignItemWithTrigger={false}>
2052 <SelectItem hideIndicator value="inherit">
2053 Use global default
2054 </SelectItem>
2055 <SelectItem hideIndicator value="repository">
2056 {PROJECT_GROUPING_MODE_LABELS.repository}
2057 </SelectItem>
2058 <SelectItem hideIndicator value="repository_path">
2059 {PROJECT_GROUPING_MODE_LABELS.repository_path}
2060 </SelectItem>
2061 <SelectItem hideIndicator value="separate">
2062 {PROJECT_GROUPING_MODE_LABELS.separate}
2063 </SelectItem>
2064 </SelectPopup>
2065 </Select>
2066 </label>
2067 </div>
2068 <div className="flex flex-wrap items-center gap-2 sm:pl-7">
2069 <Button
2070 size="sm"
2071 variant="ghost"
2072 onClick={() =>
2073 copyProjectPath(member.workspaceRoot, { path: member.workspaceRoot })
2074 }
2075 >
2076 <CopyIcon />
2077 Copy path
2078 </Button>
2079 <Button
2080 size="sm"
2081 variant="ghost"
2082 className="text-destructive-foreground hover:bg-destructive/8 hover:text-destructive-foreground sm:ml-auto"
2083 onClick={() => {
2084 const projectGroup = projectActionsTarget;
2085 if (!projectGroup) return;
2086 setProjectActionsTarget(null);
2087 void handleRemoveProjectMembers(projectGroup, [member]);
2088 }}
2089 >
2090 <Trash2Icon />
2091 Remove
2092 </Button>
2093 </div>
2094 </section>
2095 ))}
2096 </div>
2097 {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? (
2098 <div className="flex flex-col gap-3 border-t border-border/60 bg-muted/32 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
2099 <div className="min-w-0">
2100 <p className="text-base font-medium text-foreground sm:text-sm">
2101 Remove this project everywhere
2102 </p>
2103 <p className="text-base text-pretty text-muted-foreground sm:text-sm">
2104 Deletes all grouped entries and their conversation history.
2105 </p>
2106 </div>
2107 <Button
2108 size="sm"
2109 variant="destructive-outline"
2110 className="shrink-0"
2111 onClick={() => {
2112 const projectGroup = projectActionsTarget;
2113 setProjectActionsTarget(null);
2114 void handleRemoveProjectMembers(projectGroup, projectGroup.memberProjects);
2115 }}
2116 >
2117 <Trash2Icon />
2118 Remove all entries
2119 </Button>
2120 </div>
2121 ) : null}
2122 </DialogPanel>
2123 <DialogFooter variant="bare">
2124 <Button onClick={() => setProjectActionsTarget(null)}>Done</Button>
2125 </DialogFooter>
2126 </DialogPopup>
2127 </Dialog>
18482128 <SidebarChromeFooter />
18492129 </>
18502130 );