From 92e820f338a6489769e221eccaf4f5f414606a55 Mon Sep 17 00:00:00 2001 From: AI091 Date: Tue, 20 Oct 2026 17:28:23 +0300 Subject: [PATCH] feat(web): sidebar New starts the thread in the scoped project While the sidebar is filtered to one project, its New button opened the "New thread in..." picker and highlighted a different project. It now starts the thread in the scoped project. The viewed project wins only when it is a member of the scope, so a project on several machines keeps the viewed thread's machine. Shift+click, chat.new, chat.newLocal and the / landing are unchanged. --- apps/web/src/components/Sidebar.tsx | 8 +++++ .../SnapShotCoordinator.browser.test.ts | 1 + apps/web/src/hooks/useHandleNewThread.test.ts | 1 + apps/web/src/hooks/useHandleNewThread.ts | 10 ++++++ apps/web/src/hooks/useSidebarProjectScope.ts | 27 +++++++++++++++ apps/web/src/lib/chatThreadActions.test.ts | 34 +++++++++++++++++++ apps/web/src/lib/chatThreadActions.ts | 24 +++++++++++++ docs/user/thread-sidebar.md | 2 ++ 8 files changed, 107 insertions(+) create mode 100644 apps/web/src/hooks/useSidebarProjectScope.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bcd24bf8498f4..4947dde88af02 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4533,6 +4533,14 @@ export default function Sidebar() { }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); const handleNewThreadClick = (event?: ReactMouseEvent) => { + // A scoped sidebar already names the project. Shift+click still means + // the current project, below. + const scopedProjectRef = newThreadContext.scopedNewThreadProjectRef; + if (scopedProjectRef && !event?.shiftKey) { + if (isMobile) setOpenMobile(false); + void newThreadContext.handleNewThread(scopedProjectRef); + return; + } if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts index b38e2f7018df0..936439a8ec7d7 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts +++ b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts @@ -204,6 +204,7 @@ describe("pending snapshot processing", () => { activeDraftThread: null, activeThread: null, defaultProjectRef: routeThreadRef ? projectRef : null, + scopedNewThreadProjectRef: null, handleNewThread: vi.fn["handleNewThread"]>(), routeDraftId: null, routeThreadRef, diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 3fa77ce4b9ce4..635446c7687c4 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -197,6 +197,7 @@ vi.mock("../uiStateStore", () => ({ useUiStateStore: () => [], })); vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); +vi.mock("./useSidebarProjectScope", () => ({ useSidebarProjectScope: () => null })); import { useNewThreadHandler } from "./useHandleNewThread"; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index cfb98fc83bd01..139c30eb791fc 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -33,6 +33,7 @@ import { hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, + resolveScopedNewThreadProjectRef, } from "../lib/chatThreadActions"; import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -40,6 +41,7 @@ import { environmentServerConfigsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; +import { useSidebarProjectScope } from "./useSidebarProjectScope"; import { useThreadRouteTarget } from "./useThreadRouteTarget"; interface NewThreadWorkspaceOptions { @@ -385,6 +387,7 @@ export function useNewThreadHandler() { export function useHandleNewThread() { const projectOrder = useUiStateStore((store) => store.projectOrder); + const projectScope = useSidebarProjectScope(); const routeTarget = useThreadRouteTarget(); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const routeDraftId = routeTarget?.kind === "draft" ? routeTarget.draftId : null; @@ -416,6 +419,13 @@ export function useHandleNewThread() { activeDraftThread, activeThread, defaultProjectRef, + // Where the sidebar's New button lands while scoped; null when unscoped. + scopedNewThreadProjectRef: projectScope + ? resolveScopedNewThreadProjectRef( + { activeDraftThread, activeThread: activeThread ?? undefined }, + projectScope, + ) + : null, handleNewThread, routeDraftId, routeThreadRef, diff --git a/apps/web/src/hooks/useSidebarProjectScope.ts b/apps/web/src/hooks/useSidebarProjectScope.ts new file mode 100644 index 0000000000000..fbdc84a80ba1b --- /dev/null +++ b/apps/web/src/hooks/useSidebarProjectScope.ts @@ -0,0 +1,27 @@ +import { buildProjectGroups, selectProjectGroupingSettings } from "../logicalProject"; +import { useProjects } from "../state/entities"; +import { usePrimaryEnvironmentId } from "../state/environments"; +import { useUiStateStore } from "../uiStateStore"; +import { useClientSettings, useLegacySidebarEnabled } from "./useSettings"; + +/** + * The project group the sidebar is scoped to, or null when it lists every + * project. The sidebar's New button defaults into it so the thread lands in the + * list the user is looking at. The legacy sidebar never shows the scope, so it + * never applies. + */ +export function useSidebarProjectScope() { + const projectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey); + const legacySidebarEnabled = useLegacySidebarEnabled(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projects = useProjects(); + if (legacySidebarEnabled || projectScopeKey === null) return null; + return ( + buildProjectGroups({ + projects, + settings: projectGroupingSettings, + preferredEnvironmentId: primaryEnvironmentId, + }).find((group) => group.key === projectScopeKey) ?? null + ); +} diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index c145404a74e2d..1dc452b8eae42 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -11,13 +11,23 @@ import { hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, + resolveScopedNewThreadProjectRef, startNewThreadFromContext, type ChatThreadActionContext, } 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", @@ -138,6 +148,30 @@ describe("chatThreadActions", () => { expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); }); + 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("inherits only the project from context, never branch or worktree state", async () => { const handleNewThread = vi.fn(async () => {}); 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 33dfbb66ca26c..77b1ca2eb6874 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -9,6 +9,8 @@ On web and desktop, a new thread keeps the current project and carries your mode 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. When you change a new thread's project, T3 Code stays in the current environment if that project exists there. Otherwise it selects an environment that has it.