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
64 changes: 64 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import {
shouldCreateNewThreadInCurrentProject,
resolveScopedNewThreadProjectRef,
deleteSelectedThreadEntries,
getFallbackThreadIdAfterDelete,
hasUnseenCompletion,
Expand Down Expand Up @@ -1650,3 +1651,66 @@ describe("shouldCreateNewThreadInCurrentProject", () => {
expect(shouldCreateNewThreadInCurrentProject(true, 2, 2)).toBe(true);
});
});

describe("resolveScopedNewThreadProjectRef", () => {
const local = { environmentId: "env-local", projectId: "api" };
const remote = { environmentId: "env-remote", projectId: "api-remote" };
const api = { environmentId: "env-local", id: "api", memberProjectRefs: [local, remote] };
const web = {
environmentId: "env-local",
id: "web",
memberProjectRefs: [{ environmentId: "env-local", projectId: "web" }],
};
const viewedWeb = { environmentId: "env-local", projectId: "web" };

it("starts in the one scoped project even while another project's thread is open", () => {
expect(
resolveScopedNewThreadProjectRef({
shiftKey: false,
scopedProjectGroups: [api],
viewed: viewedWeb,
}),
).toBe(local);
expect(
resolveScopedNewThreadProjectRef({
shiftKey: false,
scopedProjectGroups: [api],
viewed: null,
}),
).toBe(local);
});

it("keeps the viewed thread's environment when that thread is in the scoped project", () => {
expect(
resolveScopedNewThreadProjectRef({
shiftKey: false,
scopedProjectGroups: [api],
viewed: { environmentId: "env-remote", projectId: "api-remote" },
}),
).toBe(remote);
});

it("leaves Shift+click, no scope and several scoped projects to the existing rule", () => {
expect(
resolveScopedNewThreadProjectRef({
shiftKey: true,
scopedProjectGroups: [api],
viewed: viewedWeb,
}),
).toBeNull();
expect(
resolveScopedNewThreadProjectRef({
shiftKey: false,
scopedProjectGroups: [],
viewed: viewedWeb,
}),
).toBeNull();
expect(
resolveScopedNewThreadProjectRef({
shiftKey: false,
scopedProjectGroups: [api, web],
viewed: viewedWeb,
}),
).toBeNull();
});
});
29 changes: 29 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,35 @@ export function shouldCreateNewThreadInCurrentProject(
return shiftKey || openProjectGroupCount === 1 || projectGroupCount === 0;
}

/**
* A sidebar filtered to one project already says where the next thread belongs,
* so New skips the picker there. A project grouped across environments keeps the
* viewed thread's environment when that thread belongs to the group.
*/
export function resolveScopedNewThreadProjectRef<
TRef extends { readonly environmentId: string; readonly projectId: string },
>(input: {
shiftKey: boolean;
scopedProjectGroups: readonly {
readonly environmentId: string;
readonly id: string;
readonly memberProjectRefs: readonly TRef[];
}[];
viewed: { readonly environmentId: string; readonly projectId: string } | null | undefined;
}): TRef | null {
const [group, ...otherGroups] = input.scopedProjectGroups;
if (input.shiftKey || !group || otherGroups.length > 0) return null;
const memberAt = (environmentId: string, projectId: string) =>
group.memberProjectRefs.find(
(ref) => ref.environmentId === environmentId && ref.projectId === projectId,
);
return (
(input.viewed && memberAt(input.viewed.environmentId, input.viewed.projectId)) ??
memberAt(group.environmentId, group.id) ??
null
);
}

export function orderItemsByPreferredIds<TItem, TId>(input: {
items: readonly TItem[];
preferredIds: readonly TId[];
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ import {
resolveSidebarReorderableThreadKeys,
searchSidebarThreads,
shouldCreateNewThreadInCurrentProject,
resolveScopedNewThreadProjectRef,
shouldNavigateAfterThreadPark,
sidebarListItemId,
sidebarThreadKeyAtY,
Expand Down Expand Up @@ -2935,6 +2936,17 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
const showJumpHints = useThreadJumpHintsVisible(keybindings, routeThreadRef);

const handleNewThreadClick = (event?: ReactMouseEvent) => {
const scopedProjectRef = resolveScopedNewThreadProjectRef({
shiftKey: event?.shiftKey ?? false,
// The folder sidebar's header button always opens the picker.
scopedProjectGroups: boringSidebarEnabled ? [] : scopedProjectGroups,
viewed: newThreadContext.activeThread ?? newThreadContext.activeDraftThread,
});
if (scopedProjectRef) {
if (isMobile) setOpenMobile(false);
void handleNewThread(scopedProjectRef);
return;
}
if (
shouldCreateNewThreadInCurrentProject(
event?.shiftKey ?? false,
Expand Down
2 changes: 2 additions & 0 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ the current project, or use a project's own new-thread action. A new thread carr
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.
While the sidebar is filtered to one project, the sidebar's **New thread** button
starts the thread in that project instead of asking which project to use.

On mobile, **New thread** starts in the current thread's project, or else in the
last project you opened or chose on that device. If neither is available, the
Expand Down
Loading