diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0e3f47b7f9ed8..335e7074967ae 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -7,6 +7,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { shouldCreateNewThreadInCurrentProject, + resolveScopedNewThreadProjectRef, deleteSelectedThreadEntries, getFallbackThreadIdAfterDelete, hasUnseenCompletion, @@ -1650,3 +1651,66 @@ describe("shouldCreateNewThreadInCurrentProject", () => { expect(shouldCreateNewThreadInCurrentProject(true, 2, 2)).toBe(true); }); }); + +describe("resolveScopedNewThreadProjectRef", () => { + const local = { environmentId: "env-local", projectId: "api" }; + const remote = { environmentId: "env-remote", projectId: "api-remote" }; + const api = { environmentId: "env-local", id: "api", memberProjectRefs: [local, remote] }; + const web = { + environmentId: "env-local", + id: "web", + memberProjectRefs: [{ environmentId: "env-local", projectId: "web" }], + }; + const viewedWeb = { environmentId: "env-local", projectId: "web" }; + + it("starts in the one scoped project even while another project's thread is open", () => { + expect( + resolveScopedNewThreadProjectRef({ + shiftKey: false, + scopedProjectGroups: [api], + viewed: viewedWeb, + }), + ).toBe(local); + expect( + resolveScopedNewThreadProjectRef({ + shiftKey: false, + scopedProjectGroups: [api], + viewed: null, + }), + ).toBe(local); + }); + + it("keeps the viewed thread's environment when that thread is in the scoped project", () => { + expect( + resolveScopedNewThreadProjectRef({ + shiftKey: false, + scopedProjectGroups: [api], + viewed: { environmentId: "env-remote", projectId: "api-remote" }, + }), + ).toBe(remote); + }); + + it("leaves Shift+click, no scope and several scoped projects to the existing rule", () => { + expect( + resolveScopedNewThreadProjectRef({ + shiftKey: true, + scopedProjectGroups: [api], + viewed: viewedWeb, + }), + ).toBeNull(); + expect( + resolveScopedNewThreadProjectRef({ + shiftKey: false, + scopedProjectGroups: [], + viewed: viewedWeb, + }), + ).toBeNull(); + expect( + resolveScopedNewThreadProjectRef({ + shiftKey: false, + scopedProjectGroups: [api, web], + viewed: viewedWeb, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e84d307d0eec5..5d9420dbcc777 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -847,6 +847,35 @@ export function shouldCreateNewThreadInCurrentProject( return shiftKey || openProjectGroupCount === 1 || projectGroupCount === 0; } +/** + * A sidebar filtered to one project already says where the next thread belongs, + * so New skips the picker there. A project grouped across environments keeps the + * viewed thread's environment when that thread belongs to the group. + */ +export function resolveScopedNewThreadProjectRef< + TRef extends { readonly environmentId: string; readonly projectId: string }, +>(input: { + shiftKey: boolean; + scopedProjectGroups: readonly { + readonly environmentId: string; + readonly id: string; + readonly memberProjectRefs: readonly TRef[]; + }[]; + viewed: { readonly environmentId: string; readonly projectId: string } | null | undefined; +}): TRef | null { + const [group, ...otherGroups] = input.scopedProjectGroups; + if (input.shiftKey || !group || otherGroups.length > 0) return null; + const memberAt = (environmentId: string, projectId: string) => + group.memberProjectRefs.find( + (ref) => ref.environmentId === environmentId && ref.projectId === projectId, + ); + return ( + (input.viewed && memberAt(input.viewed.environmentId, input.viewed.projectId)) ?? + memberAt(group.environmentId, group.id) ?? + null + ); +} + export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8fa58d8456f75..5794dce991828 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -195,6 +195,7 @@ import { resolveSidebarReorderableThreadKeys, searchSidebarThreads, shouldCreateNewThreadInCurrentProject, + resolveScopedNewThreadProjectRef, shouldNavigateAfterThreadPark, sidebarListItemId, sidebarThreadKeyAtY, @@ -2935,6 +2936,17 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const showJumpHints = useThreadJumpHintsVisible(keybindings, routeThreadRef); const handleNewThreadClick = (event?: ReactMouseEvent) => { + const scopedProjectRef = resolveScopedNewThreadProjectRef({ + shiftKey: event?.shiftKey ?? false, + // The folder sidebar's header button always opens the picker. + scopedProjectGroups: boringSidebarEnabled ? [] : scopedProjectGroups, + viewed: newThreadContext.activeThread ?? newThreadContext.activeDraftThread, + }); + if (scopedProjectRef) { + if (isMobile) setOpenMobile(false); + void handleNewThread(scopedProjectRef); + return; + } if ( shouldCreateNewThreadInCurrentProject( event?.shiftKey ?? false, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..df4773755a950 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -11,6 +11,8 @@ the current project, or use a project's own new-thread action. A new thread carr your model and mode selections, unless the destination project has its own model default. Its branch and workspace mode come from your configured defaults. To continue in an existing worktree, use **New thread in this worktree** from the branch toolbar. +While the sidebar is filtered to one project, the sidebar's **New thread** button +starts the thread in that project instead of asking which project to use. On mobile, **New thread** starts in the current thread's project, or else in the last project you opened or chose on that device. If neither is available, the