diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0e3f47b7f9ed..1eae25a12e55 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1643,10 +1643,15 @@ describe("resolveSidebarFolderDot", () => { describe("shouldCreateNewThreadInCurrentProject", () => { it("opens the picker when several projects are open, or only archived ones exist", () => { - expect(shouldCreateNewThreadInCurrentProject(false, 2, 3)).toBe(false); - expect(shouldCreateNewThreadInCurrentProject(false, 0, 2)).toBe(false); - expect(shouldCreateNewThreadInCurrentProject(false, 1, 3)).toBe(true); - expect(shouldCreateNewThreadInCurrentProject(false, 0, 0)).toBe(true); - expect(shouldCreateNewThreadInCurrentProject(true, 2, 2)).toBe(true); + const open = { archived: false }; + const archived = { archived: true }; + const scratch = { archived: false, isScratch: true as const }; + expect(shouldCreateNewThreadInCurrentProject(false, [open, open, archived])).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, [archived, archived])).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, [open, archived, archived])).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(false, [])).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(true, [open, open])).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(false, [open, scratch])).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(false, [archived, scratch])).toBe(false); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ef229d1424c3..3159bbc2a017 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -741,10 +741,12 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { /** The "New thread in" picker opens unless there is one open project or none; then the current context decides. */ export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, - openProjectGroupCount: number, - projectGroupCount: number, + projectGroups: ReadonlyArray<{ readonly archived: boolean; readonly isScratch?: true }>, ): boolean { - return shiftKey || openProjectGroupCount === 1 || projectGroupCount === 0; + // "No project" is always offered as its own row, so it does not make the choice ambiguous. + const realGroups = projectGroups.filter((group) => !group.isScratch); + const openGroupCount = realGroups.filter((group) => !group.archived).length; + return shiftKey || openGroupCount === 1 || realGroups.length === 0; } export function orderItemsByPreferredIds(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 75b225a8b435..120dc76d1b9c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2961,13 +2961,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const showJumpHints = useThreadJumpHintsVisible(shouldShowJumpHintsNow); const handleNewThreadClick = (event?: ReactMouseEvent) => { - if ( - shouldCreateNewThreadInCurrentProject( - event?.shiftKey ?? false, - scopeGroups.length, - projectGroups.length, - ) - ) { + if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups)) { if (isMobile) setOpenMobile(false); // With no current or open project, such as when every project is archived, pick one. void startNewThreadFromContext({ @@ -2986,7 +2980,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? - (shouldCreateNewThreadInCurrentProject(false, scopeGroups.length, projectGroups.length) + (shouldCreateNewThreadInCurrentProject(false, projectGroups) ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); @@ -3208,11 +3202,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve newThreadShortcutLabel={newThreadShortcutLabel} newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} showNewThreadInProjectHint={ - !shouldCreateNewThreadInCurrentProject( - false, - scopeGroups.length, - projectGroups.length, - ) + !shouldCreateNewThreadInCurrentProject(false, projectGroups) } searchInputRef={threadSearchInputRef} searchQuery={threadSearchQuery} diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6f318bce409c..84c1f6e72175 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -404,4 +404,31 @@ describe("default project selection", () => { ), ).toEqual({ environmentId: "first", projectId: "project" }); }); + + it("keeps a scratch destination for captures without preferring it over a real project", () => { + const project = { + id: "project" as ProjectId, + environmentId: "environment-primary" as EnvironmentId, + title: "No project", + workspaceRoot: "/scratch", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const scratch = { ...project, id: "scratch" as ProjectId, isScratch: true as const }; + const scratchRef = { environmentId: scratch.environmentId, projectId: scratch.id }; + expect(resolveDefaultProjectRef([scratch], [], null)).toEqual(scratchRef); + expect(resolveDefaultProjectRef([scratch, project], [], null)).toEqual({ + environmentId: project.environmentId, + projectId: project.id, + }); + expect( + resolveDefaultProjectRef([scratch, { ...project, archivedAt: scratch.createdAt }], [], null), + ).toEqual(scratchRef); + expect( + resolveDefaultProjectRef([{ ...scratch, archivedAt: scratch.createdAt }], [], null), + ).toBeNull(); + expect(resolveDefaultProjectRef([], [], null)).toBeNull(); + }); }); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 44215edfc5be..df62135d5fc0 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -451,7 +451,7 @@ export function resolveDefaultProjectRef( ); if (lastUsed) return scopeProjectRef(lastUsed.environmentId, lastUsed.id); if (!fallbackReady) return null; - const [defaultProject] = orderItemsByPreferredIds({ + const orderedProjects = orderItemsByPreferredIds({ items: projects.filter((project) => project.archivedAt == null), preferredIds: projectOrder, getId: getProjectOrderKey, @@ -460,6 +460,8 @@ export function resolveDefaultProjectRef( legacyProjectCwdPreferenceKey(project.workspaceRoot), ], }); + const defaultProject = + orderedProjects.find((project) => !project.isScratch) ?? orderedProjects[0]; return defaultProject ? scopeProjectRef(defaultProject.environmentId, defaultProject.id) : null; } diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index 9b2e59f76d7e..24e04b86851a 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -131,14 +131,7 @@ export function installChatRouteCommands(router: AnyRouter): () => void { void scratch.startScratchThread(environmentId); }, "chat.new": (event) => { - const groups = readProjectGroups(); - if ( - !shouldCreateNewThreadInCurrentProject( - false, - groups.filter((group) => !group.archived).length, - groups.length, - ) - ) { + if (!shouldCreateNewThreadInCurrentProject(false, readProjectGroups())) { claim(event); openCommandPalette({ open: "new-thread-in" }); return;