From 5edfc4135eb54198e2be4037f3797752c47f3309 Mon Sep 17 00:00:00 2001 From: Oleg Bezrukavnikov Date: Mon, 2 Nov 2026 16:57:56 -0800 Subject: [PATCH 1/2] fix(web): New thread skips the picker when "No project" is the only other choice The "New thread in" picker is skipped when exactly one project is open. The scratch project behind "No project" counted as a second project, so anyone who had ever started a No project thread got the picker every time. Ignore the scratch project when deciding whether the choice is ambiguous, and when picking the default project for a new thread. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 15 ++++++++++----- apps/web/src/components/Sidebar.logic.ts | 8 +++++--- apps/web/src/components/Sidebar.tsx | 16 +++------------- apps/web/src/hooks/useHandleNewThread.ts | 2 +- apps/web/src/shortcuts/chatRouteCommands.ts | 9 +-------- 5 files changed, 20 insertions(+), 30 deletions(-) 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.ts b/apps/web/src/hooks/useHandleNewThread.ts index a43825472f35..38d9d0e589f2 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -428,7 +428,7 @@ export function resolveDefaultProjectRef( projectOrder: readonly string[], ): ScopedProjectRef | null { const [defaultProject] = orderItemsByPreferredIds({ - items: projects.filter((project) => project.archivedAt == null), + items: projects.filter((project) => project.archivedAt == null && !project.isScratch), preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index 70848449b2ef..c13e372b70ee 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -123,14 +123,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; From 5d7d7fa819ddc743cbf765c4acb4898323444978 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 17:59:34 +0000 Subject: [PATCH 2/2] fix(web): retain scratch as the default project fallback --- apps/web/src/hooks/useHandleNewThread.test.ts | 37 +++++++++++++++++-- apps/web/src/hooks/useHandleNewThread.ts | 6 ++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6faf5bd76d73..b5b800edfbca 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import type { RuntimeMode } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, RuntimeMode } from "@t3tools/contracts"; const testState = vi.hoisted(() => { let completeProjectFileRead: (value: null) => void = () => undefined; @@ -183,7 +183,9 @@ vi.mock("react", () => ({ useCallback: (callback: T) => callback, useMemo: (factory: () => T) => factory(), })); -vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] })); +vi.mock("../components/Sidebar.logic", () => ({ + orderItemsByPreferredIds: (input: { items: readonly T[] }) => [...input.items], +})); vi.mock("../composerDraftStore", () => { const useComposerDraftStore = Object.assign(() => null, { getState: () => testState.draftStore, @@ -247,7 +249,7 @@ vi.mock("../uiStateStore", () => ({ })); vi.mock("./useSettings", () => ({ getClientSettings: () => ({}) })); -import { useNewThreadHandler } from "./useHandleNewThread"; +import { resolveDefaultProjectRef, useNewThreadHandler } from "./useHandleNewThread"; describe.each([ ["new", null], @@ -365,3 +367,32 @@ describe("useNewThreadHandler", () => { expect(testState.router.navigate).toHaveBeenCalledOnce(); }); }); + +describe("resolveDefaultProjectRef", () => { + 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], [])).toEqual(scratchRef); + expect(resolveDefaultProjectRef([scratch, project], [])).toEqual({ + environmentId: project.environmentId, + projectId: project.id, + }); + expect( + resolveDefaultProjectRef([scratch, { ...project, archivedAt: scratch.createdAt }], []), + ).toEqual(scratchRef); + expect( + resolveDefaultProjectRef([{ ...scratch, archivedAt: scratch.createdAt }], []), + ).toBeNull(); + expect(resolveDefaultProjectRef([], [])).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 38d9d0e589f2..ccbf90af12f0 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -427,8 +427,8 @@ export function resolveDefaultProjectRef( projects: ReadonlyArray, projectOrder: readonly string[], ): ScopedProjectRef | null { - const [defaultProject] = orderItemsByPreferredIds({ - items: projects.filter((project) => project.archivedAt == null && !project.isScratch), + const orderedProjects = orderItemsByPreferredIds({ + items: projects.filter((project) => project.archivedAt == null), preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ @@ -436,6 +436,8 @@ export function resolveDefaultProjectRef( legacyProjectCwdPreferenceKey(project.workspaceRoot), ], }); + const defaultProject = + orderedProjects.find((project) => !project.isScratch) ?? orderedProjects[0]; return defaultProject ? scopeProjectRef(defaultProject.environmentId, defaultProject.id) : null; }