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 bcd24bf8498f..4947dde88af0 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 b38e2f7018df..936439a8ec7d 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 3fa77ce4b9ce..635446c7687c 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 cfb98fc83bd0..139c30eb791f 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 000000000000..fbdc84a80ba1 --- /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 c145404a74e2..1dc452b8eae4 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 ac5412c23975..c6e6fac37d77 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 33dfbb66ca26..77b1ca2eb687 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.