Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/web/src/components/Sidebar.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SidebarProvider>
<Sidebar routeReveal={{ observedKey: null, pendingKey: null }} />
</SidebarProvider>,
);

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);
}
});
});
5 changes: 3 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -2914,7 +2915,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve

const handleNewThreadClick = (event?: ReactMouseEvent) => {
if (
boringSidebarEnabled ||
legacySidebarEnabled ||
shouldCreateNewThreadInCurrentProject(
event?.shiftKey ?? false,
scopeGroups.length,
Expand Down Expand Up @@ -3143,7 +3144,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
newThreadShortcutLabel={newThreadShortcutLabel}
newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel}
showNewThreadInProjectHint={
!boringSidebarEnabled &&
!legacySidebarEnabled &&
!shouldCreateNewThreadInCurrentProject(
false,
scopeGroups.length,
Expand Down
65 changes: 65 additions & 0 deletions apps/web/src/shortcuts/chatRouteCommands.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
5 changes: 1 addition & 4 deletions apps/web/src/shortcuts/chatRouteCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading