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 e6f23149dfedb..e13bcaa8dfd6c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -980,6 +980,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. @@ -2914,7 +2915,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const handleNewThreadClick = (event?: ReactMouseEvent) => { if ( - boringSidebarEnabled || + legacySidebarEnabled || shouldCreateNewThreadInCurrentProject( event?.shiftKey ?? false, scopeGroups.length, @@ -3143,7 +3144,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve newThreadShortcutLabel={newThreadShortcutLabel} newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} showNewThreadInProjectHint={ - !boringSidebarEnabled && + !legacySidebarEnabled && !shouldCreateNewThreadInCurrentProject( false, scopeGroups.length, 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 5ddb93f30e8b2..cb9474c9a9237 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -123,12 +123,9 @@ 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(); + const { legacySidebarEnabled } = getClientSettings(); const groups = readProjectGroups(); if ( - !boringSidebarEnabled && !legacySidebarEnabled && !shouldCreateNewThreadInCurrentProject( false, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index a90dabf06d8c8..517953cb4fd63 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -5,8 +5,11 @@ 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. 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 an existing worktree, use **New thread in this worktree** from the branch toolbar.