diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 75b225a8b4351..c2bc653699897 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -134,7 +134,10 @@ import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { registerSettleAndAdvance } from "../settleAndAdvance"; -import { startNewThreadFromContext } from "../lib/chatThreadActions"; +import { + resolveScopedNewThreadProjectRef, + startNewThreadFromContext, +} from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; @@ -2961,6 +2964,21 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const showJumpHints = useThreadJumpHintsVisible(shouldShowJumpHintsNow); const handleNewThreadClick = (event?: ReactMouseEvent) => { + const scopedProject = + !boringSidebarEnabled && chosenProjectGroups.length === 1 ? chosenProjectGroups[0] : null; + if (scopedProject && !event?.shiftKey) { + if (isMobile) setOpenMobile(false); + void handleNewThread( + resolveScopedNewThreadProjectRef( + { + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + }, + { representative: scopedProject, memberProjectRefs: scopedProject.memberProjectRefs }, + ), + ); + return; + } if ( shouldCreateNewThreadInCurrentProject( event?.shiftKey ?? false, diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index ad465cd06b2bf..3d388df6707e0 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -1,7 +1,28 @@ -import { ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + type ModelSelection, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveNewThreadModelSelectionOverride } from "./chatThreadActions"; +import { + resolveScopedNewThreadProjectRef, + resolveNewThreadModelSelectionOverride, +} from "./chatThreadActions"; +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-2"); +const PROJECT_ID = ProjectId.make("project-1"); +const FALLBACK_PROJECT_ID = ProjectId.make("project-2"); +const REMOTE_PROJECT_ID = ProjectId.make("project-3"); +const SCOPE = { + representative: { environmentId: ENVIRONMENT_ID, id: PROJECT_ID }, + memberProjectRefs: [ + scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), + scopeProjectRef(REMOTE_ENVIRONMENT_ID, REMOTE_PROJECT_ID), + ], +}; const PROJECT_DEFAULT_SELECTION: ModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "project-default", @@ -12,6 +33,30 @@ const CARRIED_SELECTION: ModelSelection = { }; describe("chatThreadActions", () => { + it("keeps a viewed thread inside the scope on its own machine", () => { + const projectRef = resolveScopedNewThreadProjectRef( + { + activeThread: { environmentId: REMOTE_ENVIRONMENT_ID, projectId: REMOTE_PROJECT_ID }, + activeDraftThread: null, + }, + SCOPE, + ); + + expect(projectRef).toEqual(scopeProjectRef(REMOTE_ENVIRONMENT_ID, REMOTE_PROJECT_ID)); + }); + + it("uses the scoped project when the viewed thread is outside the scope", () => { + const projectRef = resolveScopedNewThreadProjectRef( + { + activeThread: { environmentId: ENVIRONMENT_ID, projectId: FALLBACK_PROJECT_ID }, + activeDraftThread: null, + }, + SCOPE, + ); + + expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); + }); + it("does not carry a non-explicit model from the destination draft back into itself", () => { expect( resolveNewThreadModelSelectionOverride({ diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index ac5412c239753..c6e6fac37d772 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -82,6 +82,30 @@ export function resolveThreadActionProjectRef( return context.defaultProjectRef; } +// New chat from a scoped sidebar lands in the scoped project, so the thread +// shows up in the list the user is looking at. The viewed project wins only +// when it is a member of the scope, which keeps the viewed thread's machine. +export function resolveScopedNewThreadProjectRef( + context: Pick, + scope: { + readonly representative: { readonly environmentId: EnvironmentId; readonly id: ProjectId }; + readonly memberProjectRefs: readonly ScopedProjectRef[]; + }, +): ScopedProjectRef { + const current = context.activeThread ?? context.activeDraftThread; + if ( + current && + scope.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === current.environmentId && + projectRef.projectId === current.projectId, + ) + ) { + return scopeProjectRef(current.environmentId, current.projectId); + } + return scopeProjectRef(scope.representative.environmentId, scope.representative.id); +} + export async function startNewThreadFromContext( context: ChatThreadActionContext, ): Promise { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..df4773755a950 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -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