Skip to content
Open
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
15 changes: 10 additions & 5 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
8 changes: 5 additions & 3 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TItem, TId>(input: {
Expand Down
16 changes: 3 additions & 13 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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");
Expand Down Expand Up @@ -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}
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/hooks/useHandleNewThread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
4 changes: 3 additions & 1 deletion apps/web/src/hooks/useHandleNewThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}

Expand Down
9 changes: 1 addition & 8 deletions apps/web/src/shortcuts/chatRouteCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading