diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 72b5f8f6b21..084cf0956be 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -35,6 +35,7 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; + import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 2078b0ffc41..ede8285c848 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -7,16 +7,18 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { CheckIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, + CopyIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, + EllipsisIcon, MessageSquareIcon, PlusIcon, SearchIcon, @@ -58,9 +60,14 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; -import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; +import { + deriveProjectGroupingOverrideKey, + getProjectOrderKey, + selectProjectGroupingSettings, +} from "../logicalProject"; import { buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -69,7 +76,8 @@ import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; @@ -102,8 +110,20 @@ import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../pr import { primaryServerProvidersAtom } from "../state/server"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { CommandDialogTrigger } from "./ui/command"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; import { Kbd } from "./ui/kbd"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; @@ -113,6 +133,11 @@ import { useComposerDraftStore } from "../composerDraftStore"; // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; +const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -736,6 +761,32 @@ export default function SidebarV2() { const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); + const updateProject = useAtomCommand(projectEnvironment.update, { + reportFailure: false, + }); + const updateSettings = useUpdateClientSettings(); + const { copyToClipboard: copyProjectPath } = useCopyToClipboard<{ path: string }>({ + onCopy: ({ path }) => { + toastManager.add({ + type: "success", + title: "Path copied", + description: path, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + }); + const [projectActionsTarget, setProjectActionsTarget] = useState( + null, + ); + const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), @@ -893,41 +944,50 @@ export default function SidebarV2() { clearSelection(); }, [clearSelection, projectScopeKey]); - const handleRemoveProject = useCallback( - async (projectGroup: SidebarProjectSnapshot) => { + const handleRemoveProjectMembers = useCallback( + async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => { const api = readLocalApi(); if (!api) return; - const memberKeys = new Set( - projectGroup.memberProjectRefs.map( - (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, - ), - ); + const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`)); const projectThreads = threads.filter((thread) => memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); + const isWholeGroup = members.length === projectGroup.memberProjects.length; + const singleMember = members.length === 1 ? members[0]! : null; + const targetLabel = singleMember?.title ?? projectGroup.displayName; const confirmed = await settlePromise(() => api.dialogs.confirm( projectThreads.length > 0 ? [ - `Remove project "${projectGroup.displayName}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, - ...(projectGroup.memberProjects.length === 1 - ? [`Path: ${projectGroup.workspaceRoot}`] - : [ - `This removes ${projectGroup.memberProjects.length} grouped project entries.`, - ]), + `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, + ...(singleMember + ? [ + `Path: ${singleMember.workspaceRoot}`, + ...(singleMember.environmentLabel + ? [`Environment: ${singleMember.environmentLabel}`] + : []), + ] + : [`This removes ${members.length} grouped project entries.`]), "This permanently clears conversation history for those threads.", - "This removes only the project entries, not the files on disk.", + isWholeGroup + ? "This removes only the project entries, not the files on disk." + : "Other entries in this grouped project are unaffected.", "This action cannot be undone.", ].join("\n") : [ - `Remove project "${projectGroup.displayName}"?`, - ...(projectGroup.memberProjects.length === 1 - ? [`Path: ${projectGroup.workspaceRoot}`] - : [ - `This removes ${projectGroup.memberProjects.length} grouped project entries.`, - ]), - "This removes only the project entries, not the files on disk.", + `Remove project "${targetLabel}"?`, + ...(singleMember + ? [ + `Path: ${singleMember.workspaceRoot}`, + ...(singleMember.environmentLabel + ? [`Environment: ${singleMember.environmentLabel}`] + : []), + ] + : [`This removes ${members.length} grouped project entries.`]), + isWholeGroup + ? "This removes only the project entries, not the files on disk." + : "Other entries in this grouped project are unaffected.", ].join("\n"), ), ); @@ -935,7 +995,7 @@ export default function SidebarV2() { const draftStore = useComposerDraftStore.getState(); let shouldNavigate = false; - for (const project of projectGroup.memberProjects) { + for (const project of members) { const memberThreads = projectThreads.filter( (thread) => thread.environmentId === project.environmentId && thread.projectId === project.id, @@ -986,6 +1046,56 @@ export default function SidebarV2() { [deleteProject, router, threads], ); + const renameProjectMember = useCallback( + async (member: SidebarProjectGroupMember, nextTitle: string) => { + const title = nextTitle.trim(); + if (!title) { + toastManager.add({ type: "warning", title: "Project title cannot be empty" }); + return; + } + if (title === member.title) return; + const result = await updateProject({ + environmentId: member.environmentId, + input: { projectId: member.id, title }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [updateProject], + ); + + const updateProjectGroupingPreference = useCallback( + (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { + const overrideKey = deriveProjectGroupingOverrideKey(member); + const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; + if (selection === "inherit") { + delete nextOverrides[overrideKey]; + } else { + nextOverrides[overrideKey] = selection; + } + updateSettings({ sidebarProjectGroupingOverrides: nextOverrides }); + }, + [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings], + ); + + const handleProjectActions = useCallback( + (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { + event.preventDefault(); + event.stopPropagation(); + setProjectScopeMenuOpen(false); + window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup)); + }, + [], + ); + // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells: no archived-snapshot // merging, no optimistic holds. Archived threads remain hidden here — @@ -1626,7 +1736,7 @@ export default function SidebarV2() { {projectGroups.length > 0 ? (
- + {project.displayName} ); @@ -1845,6 +1953,178 @@ export default function SidebarV2() { ) : null} + { + if (!open) setProjectActionsTarget(null); + }} + > + + + Project settings + + {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 + ? `${projectActionsTarget.displayName} has an entry in each environment. Changes apply only to the entry you choose.` + : `Manage ${projectActionsTarget?.displayName ?? "this project"} in this environment.`} + + + +
+ {projectActionsTarget?.memberProjects.map((member) => ( +
+
+ +
+
+ +

+ {member.environmentLabel ?? "Current environment"} +

+
+

+ {member.workspaceRoot} +

+
+
+
+ + +
+
+ + +
+
+ ))} +
+ {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? ( +
+
+

+ Remove this project everywhere +

+

+ Deletes all grouped entries and their conversation history. +

+
+ +
+ ) : null} +
+ + + +
+
);