diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7ebf66cf61c0..db212db39995 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import { requestCustomSnooze } from "./CustomSnoozeDialog"; +import { ProjectFoldersDialog, type ProjectFoldersDialogTarget } from "./ProjectFoldersDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; @@ -2544,6 +2545,9 @@ export default function Sidebar() { }, [isMobile, router, setOpenMobile], ); + const [projectFoldersTarget, setProjectFoldersTarget] = + useState(null); + const closeProjectFoldersDialog = useCallback(() => setProjectFoldersTarget(null), []); // Anchor for the scope popup: the header search field, not its icon trigger. const headerSearchRef = useRef(null); // Safari can send a click after Ctrl+click opens settings. Ignore that one @@ -4073,10 +4077,11 @@ export default function Sidebar() { } const thread = threadByKeyRef.current.get(threadKey); if (!thread) return; - const threadWorkspacePath = - thread.worktreePath ?? - projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.workspaceRoot ?? - null; + // The physical project this thread lives in, not its group: a group + // can span checkouts on several environments. + const threadProject = + projectByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null; + const threadWorkspacePath = thread.worktreePath ?? threadProject?.workspaceRoot ?? null; // Un-settle pins the thread active until real activity clears the pin. // Environments without // the settlement capability get no lifecycle items at all. @@ -4117,6 +4122,7 @@ export default function Sidebar() { isActive: projectScopeKey === threadProjectGroup.projectKey, } : null, + hasWorkspaceFile: Boolean(threadProject?.workspaceFile), isPinned, isSettled, autoSettleEnabled: thread.autoSettleDisabledAt == null, @@ -4161,6 +4167,16 @@ export default function Sidebar() { case "project-settings": if (threadProjectGroup) openProjectSettings(threadProjectGroup); return; + case "manage-folders": + if (threadProject?.workspaceFile) { + setProjectFoldersTarget({ + environmentId: threadProject.environmentId, + projectId: threadProject.id, + title: threadProject.title, + workspaceFile: threadProject.workspaceFile, + }); + } + return; case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -5023,6 +5039,7 @@ export default function Sidebar() { + ); } diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 5c76b1680f2e..1ee6ed5cc40b 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -24,6 +24,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import GitActionsControl from "../GitActionsControl"; +import { ProjectFoldersDialog } from "../ProjectFoldersDialog"; import { isTrailingDoubleClick } from "../Sidebar.logic"; import { MultiRepoGitControl, type MultiRepoGitGroup } from "./MultiRepoGitControl"; import { type DraftId } from "~/composerDraftStore"; @@ -266,7 +267,7 @@ export const ChatHeader = memo(function ChatHeader({ }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], ); - const { openMenu, closeMenu } = useThreadActionMenu({ + const { openMenu, closeMenu, projectFoldersTarget, closeProjectFolders } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, onStartRename: startRename, @@ -421,135 +422,138 @@ export const ChatHeader = memo(function ChatHeader({ ); return ( -
- +
- {/* The project always leads the header: knowing which project a - thread lives in is priority zero, and the thread title alone - doesn't answer it. */} - {activeProject ? ( - <> - + + {/* The project always leads the header: knowing which project a + thread lives in is priority zero, and the thread title alone + doesn't answer it. */} + {activeProject ? ( + <> + + + + } + > + + + {activeProjectName} + + + New thread in {activeProjectName} + + + + / + + + ) : null} + + {renamingTitle !== null ? ( + { + if (renameCommittedRef.current) return; + commitRename(event.currentTarget.value); + }} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + /> + ) : isServerThread ? ( } > - - - {activeProjectName} - +

+ {activeThreadTitle} +

+
- New thread in {activeProjectName} + {activeThreadTitle}
-
- - / - - - ) : null} - - {renamingTitle !== null ? ( - { - if (renameCommittedRef.current) return; - commitRename(event.currentTarget.value); - }} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - /> - ) : isServerThread ? ( - - - } - > -

+ ) : ( + + } + > {activeThreadTitle} -

- -
- {activeThreadTitle} -
- ) : ( - - } - > - {activeThreadTitle} - - {activeThreadTitle} - + + {activeThreadTitle} + + )} +
+
+
- -
- - 1))) - ? undefined - : "hidden" - } - render={ + > + + 1))) + ? undefined + : "hidden" + } + render={ +
-
+ + ); }); diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index c5ebeda55627..1b1ec5911834 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -5,6 +5,7 @@ import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./thread const baseState: ThreadActionMenuState = { branch: null, projectFilter: null, + hasWorkspaceFile: false, isPinned: false, isSettled: false, autoSettleEnabled: true, @@ -82,6 +83,16 @@ describe("buildThreadActionMenuItems", () => { expect(items[filterIndex + 1]?.id).toBe("auto-settle"); }); + it("offers Manage folders next to project settings only for workspace-file projects", () => { + expect(ids(baseState)).not.toContain("manage-folders"); + const items = buildThreadActionMenuItems({ ...baseState, hasWorkspaceFile: true }); + const settingsIndex = items.findIndex((item) => item.id === "project-settings"); + expect(items[settingsIndex + 1]).toMatchObject({ + id: "manage-folders", + label: "Manage folders...", + }); + }); + it("includes branch items only for threads with a branch", () => { const withBranch = allIds({ ...baseState, branch: "feat/menu" }); expect(withBranch).toContain("new-thread-on-branch"); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 838fc91a636c..afef63965bac 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -10,6 +10,7 @@ export type ThreadActionMenuId = | "new-thread-on-branch" | "filter-by-project" | "project-settings" + | "manage-folders" | "pin" | "unpin" | "settle" @@ -41,6 +42,11 @@ export interface ThreadActionMenuState { /** True when the list is already scoped to this thread's project. */ readonly isActive: boolean; } | null; + /** + * True when the thread's project is backed by a `.code-workspace` file. + * Plain-folder projects have no folder list to manage. + */ + readonly hasWorkspaceFile: boolean; readonly isPinned: boolean; readonly isSettled: boolean; /** False while the user has turned automatic settlement off for this thread. */ @@ -177,6 +183,9 @@ export function buildThreadActionMenuItems( ], }, { id: "project-settings", label: "Project settings", icon: "settings" }, + ...(state.hasWorkspaceFile + ? [{ id: "manage-folders" as const, label: "Manage folders...", icon: "folder" }] + : []), // Archive removes the thread from the sidebar while keeping its // conversation under Settings > Archived threads — distinct from Settle // (stays visible in the Settled shelf) and Delete (clears history for diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 4a99fa8f5c73..9a7a2fee4447 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -9,8 +9,9 @@ import { import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; +import type { ProjectFoldersDialogTarget } from "../components/ProjectFoldersDialog"; import { resolveSnoozePresets } from "../components/Sidebar.snooze"; import { buildThreadActionMenuItems, @@ -61,6 +62,9 @@ function failureToast(title: string, error: unknown) { * Unlike the sidebar, settle and snooze here never navigate away: the caller * is acting on the thread they are reading, and ChatView's parked-thread * banner already offers the way back. + * + * "Manage folders..." opens in place: the caller renders + * `ProjectFoldersDialog` with the returned `projectFoldersTarget`. */ export function useThreadActionMenu(input: { readonly threadRef: ScopedThreadRef | null; @@ -71,6 +75,9 @@ export function useThreadActionMenu(input: { const { threadRef, projectCwd, onStartRename } = input; const router = useRouter(); const projects = useProjects(); + const [projectFoldersTarget, setProjectFoldersTarget] = + useState(null); + const closeProjectFolders = useCallback(() => setProjectFoldersTarget(null), []); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const logicalProjectKeyByPhysicalKey = useMemo( @@ -141,11 +148,16 @@ export function useThreadActionMenu(input: { }; const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const project = projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, + ); const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, // The chat header has no project-scoped thread list behind the // menu, so the "Filter by project" affordance is sidebar-only. projectFilter: null, + hasWorkspaceFile: Boolean(project?.workspaceFile), isPinned: thread.pinnedAt != null, isSettled: supports.settlement && thread.settledOverride === "settled", autoSettleEnabled: thread.autoSettleDisabledAt == null, @@ -182,11 +194,6 @@ export function useThreadActionMenu(input: { }; switch (action) { case "project-settings": { - const project = projects.find( - (candidate) => - candidate.environmentId === thread.environmentId && - candidate.id === thread.projectId, - ); if (!project) return; const projectKey = logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? @@ -197,6 +204,16 @@ export function useThreadActionMenu(input: { }); return; } + case "manage-folders": + if (project?.workspaceFile) { + setProjectFoldersTarget({ + environmentId: project.environmentId, + projectId: project.id, + title: project.title, + workspaceFile: project.workspaceFile, + }); + } + return; case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -358,5 +375,5 @@ export function useThreadActionMenu(input: { void readLocalApi()?.contextMenu.close(); }, []); - return { openMenu, closeMenu }; + return { openMenu, closeMenu, projectFoldersTarget, closeProjectFolders }; }