From ca0c8b4fc10975dc79a904ccae4276dbe146ce0e Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Wed, 28 Oct 2026 19:27:39 +0530 Subject: [PATCH 1/3] fix(web): open project picker from boring sidebar --- apps/web/src/components/Sidebar.tsx | 7 +- .../src/shortcuts/chatRouteCommands.test.ts | 65 +++++++++++++++++++ apps/web/src/shortcuts/chatRouteCommands.ts | 6 +- docs/user/thread-sidebar.md | 6 +- 4 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/shortcuts/chatRouteCommands.test.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c4ba300a030c3..ab960fd1e6099 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2842,10 +2842,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const showJumpHints = useThreadJumpHintsVisible(shouldShowJumpHintsNow); const handleNewThreadClick = (event?: ReactMouseEvent) => { - if ( - boringSidebarEnabled || - shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length) - ) { + if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, @@ -3045,7 +3042,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve newThreadDisabled={projects.length === 0} newThreadShortcutLabel={newThreadShortcutLabel} newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} - showNewThreadInProjectHint={!boringSidebarEnabled && projectGroups.length > 1} + showNewThreadInProjectHint={projectGroups.length > 1} searchInputRef={threadSearchInputRef} searchQuery={threadSearchQuery} onSearchQueryChange={(value) => { diff --git a/apps/web/src/shortcuts/chatRouteCommands.test.ts b/apps/web/src/shortcuts/chatRouteCommands.test.ts new file mode 100644 index 0000000000000..6c9ff18690ec4 --- /dev/null +++ b/apps/web/src/shortcuts/chatRouteCommands.test.ts @@ -0,0 +1,65 @@ +import { DEFAULT_CLIENT_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { AnyRouter } from "@tanstack/react-router"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import * as commandPalette from "../commandPaletteBus"; +import * as settings from "../hooks/useSettings"; +import * as threadActions from "../lib/chatThreadActions"; +import * as entities from "../state/entities"; +import type { Project } from "../types"; +import { installChatRouteCommands } from "./chatRouteCommands"; +import * as dispatcher from "./dispatcher"; + +function project(id: string): Project { + return { + id: ProjectId.make(id), + environmentId: EnvironmentId.make("local"), + title: id, + workspaceRoot: `/projects/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + }; +} + +describe("new thread commands", () => { + it.each([ + { mode: "normal", count: 2, command: "chat.new", picker: true }, + { mode: "boring", count: 2, command: "chat.new", picker: true }, + { mode: "boring", count: 1, command: "chat.new", picker: false }, + { mode: "legacy", count: 2, command: "chat.new", picker: false }, + { mode: "boring", count: 2, command: "chat.newLocal", picker: false }, + ] as const)("$mode sidebar, $count projects, $command: picker=$picker", (scenario) => { + using _settings = vi.spyOn(settings, "getClientSettings").mockReturnValue({ + ...DEFAULT_CLIENT_SETTINGS, + boringSidebarEnabled: scenario.mode === "boring", + legacySidebarEnabled: scenario.mode === "legacy", + }); + using _projects = vi + .spyOn(entities, "readProjects") + .mockReturnValue([project("active"), project("idle")].slice(0, scenario.count)); + using openPicker = vi.spyOn(commandPalette, "openCommandPalette").mockImplementation(() => {}); + using startLocal = vi.spyOn(threadActions, "startNewThreadFromContext").mockResolvedValue(true); + using register = vi.spyOn(dispatcher, "registerCommands").mockReturnValue(() => {}); + const router = { state: { matches: [] } } as unknown as AnyRouter; + using stack = new DisposableStack(); + stack.defer(installChatRouteCommands(router)); + const commands = register.mock.calls[0]![0]; + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + + commands[scenario.command]!(event); + + if (scenario.picker) { + expect(openPicker).toHaveBeenCalledWith({ open: "new-thread-in" }); + expect(startLocal).not.toHaveBeenCalled(); + } else { + expect(openPicker).not.toHaveBeenCalled(); + expect(startLocal).toHaveBeenCalledOnce(); + } + }); +}); diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index f0f80dfed8830..b87b2d90d8021 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -117,10 +117,8 @@ export function installChatRouteCommands(router: AnyRouter): () => void { void scratch.startScratchThread(environmentId); }, "chat.new": (event) => { - // The boring sidebar's folders already show the context, so it starts in - // the current project like the legacy sidebar did. - const { boringSidebarEnabled, legacySidebarEnabled } = getClientSettings(); - if (!boringSidebarEnabled && !legacySidebarEnabled && readProjectGroupCount() > 1) { + const { legacySidebarEnabled } = getClientSettings(); + if (!legacySidebarEnabled && readProjectGroupCount() > 1) { claim(event); openCommandPalette({ open: "new-thread-in" }); return; diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index ce16b9df0374b..87d37fb58828d 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -5,8 +5,10 @@ need a separate branch and working directory. ## Start a thread -On web and desktop, a new thread keeps the current project and carries your model -and mode selections, unless the destination project has its own model default. +On web and desktop, **New thread** opens the project picker when you have multiple +projects in the default or project-grouped sidebar. **Shift-click** to start in +the current project, or use a project's own new-thread action. A new thread carries +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. From 56e7bae22cf2f0f39b3e04071604e15367497e3c Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Wed, 28 Oct 2026 20:05:39 +0530 Subject: [PATCH 2/3] fix(web): preserve legacy sidebar thread creation --- .../src/components/Sidebar.browser.test.tsx | 89 +++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 8 +- 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/Sidebar.browser.test.tsx diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx new file mode 100644 index 0000000000000..875a2225d70a1 --- /dev/null +++ b/apps/web/src/components/Sidebar.browser.test.tsx @@ -0,0 +1,89 @@ +import { DEFAULT_CLIENT_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { openCommandPalette } from "../commandPaletteBus"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { __setClientSettingsForTests, getClientSettings } from "../hooks/useSettings"; +import { useProjects } from "../state/entities"; +import type { Project } from "../types"; +import Sidebar from "./Sidebar"; +import { SidebarProvider } from "./ui/sidebar"; + +vi.mock("../state/entities", { spy: true }); +vi.mock("../hooks/useHandleNewThread", { spy: true }); +vi.mock("../commandPaletteBus", () => ({ + openCommandPalette: vi.fn(), + isCommandPaletteOpen: () => false, +})); +vi.mock("./sidebar/SidebarChrome", () => ({ + SidebarChromeHeader: () => null, + SidebarChromeFooter: () => null, +})); + +function project(id: string): Project { + return { + id: ProjectId.make(id), + environmentId: EnvironmentId.make("local"), + title: id, + workspaceRoot: `/projects/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + }; +} + +describe("sidebar header new thread", () => { + it.each([ + { mode: "normal", count: 2, shift: false, picker: true }, + { mode: "boring", count: 2, shift: false, picker: true }, + { mode: "boring", count: 2, shift: true, picker: false }, + { mode: "boring", count: 1, shift: false, picker: false }, + { mode: "legacy", count: 2, shift: false, picker: false }, + ] as const)("$mode sidebar, $count projects, shift=$shift: picker=$picker", async (scenario) => { + using stack = new DisposableStack(); + const previousSettings = getClientSettings(); + stack.defer(() => __setClientSettingsForTests(previousSettings)); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + boringSidebarEnabled: scenario.mode === "boring", + legacySidebarEnabled: scenario.mode === "legacy", + }); + const projects = [project("active"), project("idle")].slice(0, scenario.count); + const currentProjectRef = { + environmentId: projects[0]!.environmentId, + projectId: projects[0]!.id, + }; + const startThread = vi.fn().mockResolvedValue(null); + vi.mocked(useProjects).mockReturnValue(projects); + vi.mocked(useHandleNewThread).mockReturnValue({ + activeDraftThread: null, + activeThread: null, + defaultProjectRef: currentProjectRef, + handleNewThread: startThread, + routeDraftId: null, + routeThreadRef: null, + }); + vi.mocked(openCommandPalette).mockClear(); + await using screen = await render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "New thread", exact: true }), { + modifiers: scenario.shift ? ["Shift"] : [], + }); + + if (scenario.picker) { + expect(openCommandPalette).toHaveBeenCalledExactlyOnceWith({ open: "new-thread-in" }); + expect(startThread).not.toHaveBeenCalled(); + } else { + expect(openCommandPalette).not.toHaveBeenCalled(); + expect(startThread).toHaveBeenCalledExactlyOnceWith(currentProjectRef); + } + }); +}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ab960fd1e6099..383546d63b42e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -962,6 +962,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, }); const boringSidebarEnabled = useBoringSidebarEnabled(); + const legacySidebarEnabled = useClientSettings((settings) => settings.legacySidebarEnabled); const folderRowsMultiline = useClientSettings((settings) => settings.boringSidebarMultilineRows); const newThreadContext = useHandleNewThread(); // Row callbacks capture only the handler; the rest of the context changes with the route. @@ -2842,7 +2843,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const showJumpHints = useThreadJumpHintsVisible(shouldShowJumpHintsNow); const handleNewThreadClick = (event?: ReactMouseEvent) => { - if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { + if ( + legacySidebarEnabled || + shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length) + ) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, @@ -3042,7 +3046,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve newThreadDisabled={projects.length === 0} newThreadShortcutLabel={newThreadShortcutLabel} newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} - showNewThreadInProjectHint={projectGroups.length > 1} + showNewThreadInProjectHint={!legacySidebarEnabled && projectGroups.length > 1} searchInputRef={threadSearchInputRef} searchQuery={threadSearchQuery} onSearchQueryChange={(value) => { From 5d22142047af4ce14c74f2c481e280032a45f09e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 04:52:11 +0000 Subject: [PATCH 3/3] docs: clarify legacy sidebar thread creation --- docs/user/thread-sidebar.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 61fe00cb39e85..52e04bf80c84a 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -6,7 +6,8 @@ need a separate branch and working directory. ## Start a thread On web and desktop, **New thread** opens the project picker when you have multiple -projects in the default or project-grouped sidebar. **Shift-click** to start in +projects in the default or project-grouped sidebar. The legacy sidebar always +starts in the current project. **Shift-click** to start in the current project, or use a project's own new-thread action. A new thread carries 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