diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index d8d3d6ed1b2d..a3ba76679689 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -139,11 +139,6 @@ function ProjectProjectionRetention() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); - // The responsive Sidebar swaps its desktop container for a mobile sheet at - // the breakpoint, which remounts its contents. Keep the selected project - // scope above that boundary so resizing or closing the sheet does not clear - // the user's filter. - const [sidebarProjectScopeKey, setSidebarProjectScopeKey] = useState(null); // Settings routes show the settings nav in place of whichever thread // sidebar is active. const pathname = useLocation({ select: (location) => location.pathname }); @@ -239,10 +234,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { ) : legacySidebarEnabled ? ( ) : ( - + )} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2e90d30b2c24..da901621f884 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -67,7 +67,7 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; import { useTheme } from "../hooks/useTheme"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; @@ -161,6 +161,7 @@ import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, } from "../sidebarProjectGrouping"; +import { useScopedProjectGroup } from "../sidebarProjectScopeStore"; import type { Project } from "../types"; import { useFocusPullRequestTab, useViewPullRequest } from "../lib/viewPullRequest"; import { getSourceControlPresentation } from "../sourceControlPresentation"; @@ -652,6 +653,7 @@ function OpenCommandPaletteDialog(props: { const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const clientSettings = useClientSettings(); + const legacySidebarEnabled = useLegacySidebarEnabled(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); @@ -786,6 +788,9 @@ function OpenCommandPaletteDialog(props: { ), [clientSettings.sidebarProjectSortOrder, threads, unsortedProjectGroups], ); + // The sidebar's project filter feeds this, so the palette's "New thread in + // X" action names — and creates in — the project the sidebar is showing. + const scopedProjectGroup = useScopedProjectGroup(projectGroups); const contextualProjectRef = useMemo( () => resolveThreadActionProjectRef({ @@ -793,8 +798,9 @@ function OpenCommandPaletteDialog(props: { activeThread: activeThread ?? undefined, defaultProjectRef, handleNewThread, + scopedProjectGroup, }), - [activeDraftThread, activeThread, defaultProjectRef, handleNewThread], + [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, scopedProjectGroup], ); const projectPickerEntries = useMemo( () => @@ -1695,6 +1701,13 @@ function OpenCommandPaletteDialog(props: { const activeProjectTitle = projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null); + // Which command each item is a twin of. Mirrors the chat.new branch in + // the chat route: with the default sidebar and several projects chat.new + // opens this picker, so it belongs on the submenu and the named + // create-here item belongs to chat.newLocal. Otherwise chat.new creates + // directly, so it sits on the named item and the submenu advertises + // nothing. + const picksProject = !legacySidebarEnabled && projectGroups.length > 1; if (activeProjectTitle) { actionItems.push({ @@ -1707,13 +1720,14 @@ function OpenCommandPaletteDialog(props: { ), icon: , - shortcutCommand: "chat.new", + shortcutCommand: picksProject ? "chat.newLocal" : "chat.new", run: async () => { await startNewThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined, defaultProjectRef, handleNewThread, + scopedProjectGroup, }); }, }); @@ -1736,6 +1750,7 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , + ...(picksProject ? { shortcutCommand: "chat.new" as const } : {}), groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); } diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index c6e113a44523..bcc6292e4fce 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -33,6 +33,7 @@ import { sortThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, + shouldClearProjectScope, shouldCreateNewThreadInCurrentProject, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; @@ -435,16 +436,89 @@ describe("isSidebarNestedLinkClick", () => { describe("shouldCreateNewThreadInCurrentProject", () => { it("creates directly on shift+click in a multi-project setup", () => { - expect(shouldCreateNewThreadInCurrentProject(true, 2)).toBe(true); + expect( + shouldCreateNewThreadInCurrentProject({ + shiftKey: true, + projectGroupCount: 2, + hasProjectScope: false, + }), + ).toBe(true); }); - it("opens the picker on a plain click in a multi-project setup", () => { - expect(shouldCreateNewThreadInCurrentProject(false, 2)).toBe(false); + it("opens the picker on a plain click in an unfiltered multi-project setup", () => { + expect( + shouldCreateNewThreadInCurrentProject({ + shiftKey: false, + projectGroupCount: 2, + hasProjectScope: false, + }), + ).toBe(false); + }); + + it("creates directly when the sidebar is filtered to one project", () => { + expect( + shouldCreateNewThreadInCurrentProject({ + shiftKey: false, + projectGroupCount: 5, + hasProjectScope: true, + }), + ).toBe(true); }); it("creates directly on any click with a single project", () => { - expect(shouldCreateNewThreadInCurrentProject(false, 1)).toBe(true); - expect(shouldCreateNewThreadInCurrentProject(true, 1)).toBe(true); + expect( + shouldCreateNewThreadInCurrentProject({ + shiftKey: false, + projectGroupCount: 1, + hasProjectScope: false, + }), + ).toBe(true); + expect( + shouldCreateNewThreadInCurrentProject({ + shiftKey: true, + projectGroupCount: 1, + hasProjectScope: false, + }), + ).toBe(true); + }); +}); + +describe("shouldClearProjectScope", () => { + it("clears a filter whose project group is gone", () => { + expect( + shouldClearProjectScope({ + projectScopeKey: "gone", + scopedProjectGroup: null, + projectGroupCount: 3, + }), + ).toBe(true); + }); + + it("waits instead of clearing while projects have not arrived", () => { + expect( + shouldClearProjectScope({ + projectScopeKey: "pending", + scopedProjectGroup: null, + projectGroupCount: 0, + }), + ).toBe(false); + }); + + it("leaves a resolved filter and an absent filter alone", () => { + expect( + shouldClearProjectScope({ + projectScopeKey: "here", + scopedProjectGroup: { projectKey: "here" }, + projectGroupCount: 2, + }), + ).toBe(false); + expect( + shouldClearProjectScope({ + projectScopeKey: null, + scopedProjectGroup: null, + projectGroupCount: 0, + }), + ).toBe(false); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9cb09219df09..8972cca81a50 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -294,13 +294,32 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { // Shift+click on the new thread button creates directly in the current // project, skipping the command palette's project picker. With a single -// project there is nothing to pick, so a plain click already creates -// immediately and the modifier changes nothing. -export function shouldCreateNewThreadInCurrentProject( - shiftKey: boolean, - projectGroupCount: number, -): boolean { - return shiftKey || projectGroupCount <= 1; +// project, or with the sidebar filtered to one, the choice is already made — +// a plain click creates immediately and the modifier changes nothing. The +// button keeps one behavior per state on purpose; chat.newLocal is the way to +// start a thread beside the one on screen while the list is filtered. +export function shouldCreateNewThreadInCurrentProject(input: { + shiftKey: boolean; + projectGroupCount: number; + hasProjectScope: boolean; +}): boolean { + return input.shiftKey || input.hasProjectScope || input.projectGroupCount <= 1; +} + +/** + * Whether a project filter names a group that no longer exists and should be + * cleared. An empty group list means projects have not arrived yet rather than + * that the filter is stale, so the filter waits instead of healing — otherwise + * every remount and reconnect would silently drop it. + */ +export function shouldClearProjectScope(input: { + projectScopeKey: string | null; + scopedProjectGroup: unknown | null; + projectGroupCount: number; +}): boolean { + if (input.projectScopeKey === null) return false; + if (input.projectGroupCount === 0) return false; + return input.scopedProjectGroup === null; } export function orderItemsByPreferredIds(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 43c198204815..62f7d484e0b6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -96,6 +96,7 @@ import { } from "../sidebarProjectGrouping"; import { useComposerThreadHasDraftContent } from "../composerDraftStore"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { useScopedProjectGroup, useSidebarProjectScopeStore } from "../sidebarProjectScopeStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -135,6 +136,7 @@ import { resolveSettledTimestamp, resolveSidebarThreadStatus, searchSidebarThreadsByTitle, + shouldClearProjectScope, shouldCreateNewThreadInCurrentProject, resolveWorkingStartedAt, sortLogicalProjectsForSidebar, @@ -1701,12 +1703,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { ); }); -type SidebarProps = { - projectScopeKey: string | null; - onProjectScopeKeyChange: (projectScopeKey: string | null) => void; -}; - -export default function Sidebar({ projectScopeKey, onProjectScopeKeyChange }: SidebarProps) { +export default function Sidebar() { + const projectScopeKey = useSidebarProjectScopeStore((state) => state.projectScopeKey); + const onProjectScopeKeyChange = useSidebarProjectScopeStore((state) => state.setProjectScopeKey); const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); @@ -1924,13 +1923,7 @@ export default function Sidebar({ projectScopeKey, onProjectScopeKeyChange }: Si // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. - const scopedProjectGroup = useMemo( - () => - projectScopeKey === null - ? null - : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null), - [projectGroups, projectScopeKey], - ); + const scopedProjectGroup = useScopedProjectGroup(projectGroups); const scopedProjectKeys = useMemo( () => scopedProjectGroup === null @@ -1943,10 +1936,16 @@ export default function Sidebar({ projectScopeKey, onProjectScopeKeyChange }: Si [scopedProjectGroup], ); useEffect(() => { - if (projectScopeKey !== null && scopedProjectGroup === null) { + if ( + shouldClearProjectScope({ + projectScopeKey, + scopedProjectGroup, + projectGroupCount: projectGroups.length, + }) + ) { onProjectScopeKeyChange(null); } - }, [onProjectScopeKeyChange, projectScopeKey, scopedProjectGroup]); + }, [onProjectScopeKeyChange, projectGroups.length, projectScopeKey, scopedProjectGroup]); // Count-only subscription: the parent needs "are there draft rows" for the // empty state, while SidebarDraftBlock owns the per-keystroke content // subscription. Selecting a number keeps typing in a draft composer from @@ -3335,42 +3334,62 @@ export default function Sidebar({ projectScopeKey, onProjectScopeKeyChange }: Si autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); - // New thread defaults to the project you're in (active thread's project, - // falling back to the top project) — same resolution the command palette - // uses. The command palette already offers a "New thread in..." submenu - // for multi-project setups. + // This button belongs to the filtered list, so it creates in the filtered + // project and only falls back to the project you're in. chat.newLocal is + // the other way round — it starts a thread beside your current work — and + // the command palette offers a "New thread in..." submenu for picking. + const threadActionContext = useMemo( + () => ({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + scopedProjectGroup, + }), + [newThreadContext, scopedProjectGroup], + ); const handleNewThreadClick = useCallback( (event?: ReactMouseEvent) => { - // One project: nothing to pick, create immediately. Shift+click creates - // directly in the current project even with several projects, skipping - // the palette picker. - if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { + // One project, or a filtered list: nothing left to pick, create + // immediately. Shift+click creates directly in the current project even + // with several projects, skipping the palette picker. + if ( + shouldCreateNewThreadInCurrentProject({ + shiftKey: event?.shiftKey ?? false, + projectGroupCount: projectGroups.length, + hasProjectScope: scopedProjectGroup !== null, + }) + ) { if (isMobile) setOpenMobile(false); - void startNewThreadFromContext({ - activeDraftThread: newThreadContext.activeDraftThread, - activeThread: newThreadContext.activeThread ?? undefined, - defaultProjectRef: newThreadContext.defaultProjectRef, - handleNewThread: newThreadContext.handleNewThread, - }); + void startNewThreadFromContext(threadActionContext, "sidebar"); return; } if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); }, - [isMobile, newThreadContext, projectGroups.length, setOpenMobile], + [isMobile, projectGroups.length, scopedProjectGroup, setOpenMobile, threadActionContext], ); - // The button mirrors chat.new: in multi-project setups both route through - // the command palette's "New thread in..." picker, and in single-project - // setups both create immediately. In multi-project setups the label is only - // the picker's shortcut: falling back to chat.newLocal would advertise the - // same shortcut for both the picker and direct create. In single-project - // setups both commands create directly, so chat.newLocal is a valid - // fallback. The second tooltip line (multi-project only) advertises - // shift+click and its keyboard twin chat.newLocal for direct create. + const newThreadPicksProject = !shouldCreateNewThreadInCurrentProject({ + shiftKey: false, + projectGroupCount: projectGroups.length, + hasProjectScope: scopedProjectGroup !== null, + }); + // The tooltip must not advertise a shortcut that lands somewhere other than + // the button. chat.new matches while there is still a project to pick, and + // with a single project where both commands create directly (chat.newLocal + // stands in there when chat.new is unbound, but never while the picker is + // in play, which would advertise one shortcut for two behaviors). A filter + // leaves the button with no twin at all — chat.new still opens the chooser + // and chat.newLocal follows the thread on screen — so it names none. const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + newThreadPicksProject || projectGroups.length <= 1 + ? (shortcutLabelForCommand(keybindings, "chat.new") ?? + (newThreadPicksProject ? undefined : shortcutLabelForCommand(keybindings, "chat.newLocal"))) + : undefined; + // The second tooltip line advertises shift+click and its keyboard twin + // chat.newLocal for direct create, and is pointless once a plain click + // already creates in place. const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -3449,7 +3468,7 @@ export default function Sidebar({ projectScopeKey, onProjectScopeKeyChange }: Si /> - {projectGroups.length > 1 ? ( + {newThreadPicksProject ? ( {newThreadShortcutLabel diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 0902d8de7950..56a3f7eb0104 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -9,8 +9,20 @@ import { } 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 SCOPED_PROJECT_ID = ProjectId.make("project-3"); + +/** A filter target whose group spans two environments. */ +const SCOPED_PROJECT_GROUP = { + environmentId: ENVIRONMENT_ID, + id: SCOPED_PROJECT_ID, + memberProjectRefs: [ + scopeProjectRef(ENVIRONMENT_ID, SCOPED_PROJECT_ID), + scopeProjectRef(REMOTE_ENVIRONMENT_ID, SCOPED_PROJECT_ID), + ], +}; function createContext(overrides: Partial = {}): ChatThreadActionContext { return { @@ -74,6 +86,74 @@ describe("chatThreadActions", () => { expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); }); + it("prefers the sidebar's project filter over the active thread for the sidebar button", () => { + const projectRef = resolveThreadActionProjectRef( + createContext({ + activeThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + }, + scopedProjectGroup: SCOPED_PROJECT_GROUP, + }), + "sidebar", + ); + + expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, SCOPED_PROJECT_ID)); + }); + + it("keeps contextual commands on the active thread's project despite the filter", () => { + const projectRef = resolveThreadActionProjectRef( + createContext({ + activeThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + }, + scopedProjectGroup: SCOPED_PROJECT_GROUP, + }), + "contextual", + ); + + expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); + }); + + it("lets the filter beat the fallback default for contextual commands", () => { + const projectRef = resolveThreadActionProjectRef( + createContext({ + scopedProjectGroup: SCOPED_PROJECT_GROUP, + }), + "contextual", + ); + + expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, SCOPED_PROJECT_ID)); + }); + + it("stays on the active thread's group member when it belongs to the filtered group", () => { + const projectRef = resolveThreadActionProjectRef( + createContext({ + activeThread: { + environmentId: REMOTE_ENVIRONMENT_ID, + projectId: SCOPED_PROJECT_ID, + }, + scopedProjectGroup: SCOPED_PROJECT_GROUP, + }), + "sidebar", + ); + + expect(projectRef).toEqual(scopeProjectRef(REMOTE_ENVIRONMENT_ID, SCOPED_PROJECT_ID)); + }); + + it("uses the filtered project when there is no thread context at all", () => { + const projectRef = resolveThreadActionProjectRef( + createContext({ + defaultProjectRef: null, + scopedProjectGroup: SCOPED_PROJECT_GROUP, + }), + "sidebar", + ); + + expect(projectRef).toEqual(scopeProjectRef(ENVIRONMENT_ID, SCOPED_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 3aa7db2c2627..52e1617ae11a 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -7,6 +7,13 @@ interface ThreadContextLike { projectId: ProjectId; } +/** The sidebar project filter's target, as much of it as resolution needs. */ +interface ProjectScopeGroupLike { + readonly environmentId: EnvironmentId; + readonly id: ProjectId; + readonly memberProjectRefs: readonly ScopedProjectRef[]; +} + interface NewThreadHandler { ( projectRef: ScopedProjectRef, @@ -25,6 +32,8 @@ export interface ChatThreadActionContext { readonly activeThread: ThreadContextLike | undefined; readonly defaultProjectRef: ScopedProjectRef | null; readonly handleNewThread: NewThreadHandler; + /** The sidebar's project filter, when one is applied. */ + readonly scopedProjectGroup?: ProjectScopeGroupLike | null; } export function resolveNewDraftStartFromOrigin(input: { @@ -34,9 +43,24 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } -export function resolveThreadActionProjectRef( - context: ChatThreadActionContext, -): ScopedProjectRef | null { +/** + * Which affordance is asking, because the sidebar's project filter outranks + * different things for each. + * + * `"sidebar"` is the new thread button inside the filtered list: the filter + * beats the thread you are viewing, so the new draft always appears in the + * list you are looking at. + * + * `"contextual"` is chat.newLocal and its command palette twin — "start + * another one right here". The thread you are viewing beats the filter, or + * there would be no way to open a thread beside your current work while the + * sidebar is narrowed elsewhere. The filter still beats the fallback default, + * so with nothing open these land in the project on screen. + */ +export type NewThreadOrigin = "sidebar" | "contextual"; + +/** The project you are looking at, ignoring both filter and fallback. */ +function resolveContextualProjectRef(context: ChatThreadActionContext): ScopedProjectRef | null { if (context.activeThread) { return scopeProjectRef(context.activeThread.environmentId, context.activeThread.projectId); } @@ -46,7 +70,37 @@ export function resolveThreadActionProjectRef( context.activeDraftThread.projectId, ); } - return context.defaultProjectRef; + return null; +} + +/** The filtered project, holding the member you are already in when the group + spans environments so the same project local and remote does not snap back + to its representative. */ +function resolveScopedProjectRef(context: ChatThreadActionContext): ScopedProjectRef | null { + const scopedProjectGroup = context.scopedProjectGroup ?? null; + if (scopedProjectGroup === null) return null; + const contextualProjectRef = resolveContextualProjectRef(context); + const isContextualRefInScope = + contextualProjectRef !== null && + scopedProjectGroup.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === contextualProjectRef.environmentId && + projectRef.projectId === contextualProjectRef.projectId, + ); + return isContextualRefInScope + ? contextualProjectRef + : scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id); +} + +export function resolveThreadActionProjectRef( + context: ChatThreadActionContext, + origin: NewThreadOrigin = "contextual", +): ScopedProjectRef | null { + const scopedProjectRef = resolveScopedProjectRef(context); + if (origin === "sidebar" && scopedProjectRef !== null) { + return scopedProjectRef; + } + return resolveContextualProjectRef(context) ?? scopedProjectRef ?? context.defaultProjectRef; } // New threads inherit only the *project* from the current context. Branch, @@ -57,8 +111,9 @@ export function resolveThreadActionProjectRef( // directly instead. export async function startNewThreadFromContext( context: ChatThreadActionContext, + origin: NewThreadOrigin = "contextual", ): Promise { - const projectRef = resolveThreadActionProjectRef(context); + const projectRef = resolveThreadActionProjectRef(context, origin); if (!projectRef) { return false; } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 001d62d11adf..ac42f325fbf2 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -9,6 +9,7 @@ import { useProjects } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import { useScopedProjectGroup } from "../sidebarProjectScopeStore"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; @@ -57,16 +58,21 @@ function ChatRouteGlobalShortcuts() { ); const { viewPullRequest } = useViewPullRequest(gitStatusQuery.data, routeThreadRef); const { focusPullRequestTab } = useFocusPullRequestTab(gitStatusQuery.data, routeThreadRef); - const projectGroupCount = useMemo( + const projectGroups = useMemo( () => buildSidebarProjectSnapshots({ projects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: () => null, - }).length, + }), [primaryEnvironmentId, projectGroupingSettings, projects], ); + // These commands start a thread beside your current work, so the thread you + // are viewing outranks the sidebar's filter. The filter still beats the + // fallback default, so with nothing open they land in the project on screen + // rather than the top of the project list. + const scopedProjectGroup = useScopedProjectGroup(projectGroups); const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -110,6 +116,7 @@ function ChatRouteGlobalShortcuts() { activeThread: activeThread ?? undefined, defaultProjectRef, handleNewThread, + scopedProjectGroup, }); return; } @@ -119,8 +126,11 @@ function ChatRouteGlobalShortcuts() { event.stopPropagation(); // The default sidebar routes creation through the command palette // whenever there is a real choice to make; the legacy sidebar (and - // single-project setups) keep the immediate contextual create. - if (!legacySidebarEnabled && projectGroupCount > 1) { + // single-project setups) keep the immediate contextual create. A + // project filter does not suppress the chooser here — chat.newLocal is + // already the "create in the current project" command, so collapsing + // the two would leave no way to deliberately pick. + if (!legacySidebarEnabled && projectGroups.length > 1) { openCommandPalette({ open: "new-thread-in" }); return; } @@ -129,6 +139,7 @@ function ChatRouteGlobalShortcuts() { activeThread: activeThread ?? undefined, defaultProjectRef, handleNewThread, + scopedProjectGroup, }); return; } @@ -203,8 +214,9 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, - projectGroupCount, + projectGroups.length, routeThreadRef, + scopedProjectGroup, selectedThreadKeysSize, legacySidebarEnabled, terminalOpen, diff --git a/apps/web/src/sidebarProjectScopeStore.ts b/apps/web/src/sidebarProjectScopeStore.ts new file mode 100644 index 000000000000..b1abbb71df47 --- /dev/null +++ b/apps/web/src/sidebarProjectScopeStore.ts @@ -0,0 +1,40 @@ +/** + * The sidebar's project filter, kept outside the sidebar tree. + * + * Two consumers sit where component state could not reach them: the responsive + * sidebar remounts when it swaps its desktop container for the mobile sheet, + * and the command palette mounts at the root, above the sidebar. Every + * new-thread entry point reads this so a chosen project decides where the next + * thread lands. + */ +import { useMemo } from "react"; +import { create } from "zustand"; + +interface SidebarProjectScopeStore { + /** The scoped logical project key, or null for "All projects". */ + projectScopeKey: string | null; + setProjectScopeKey: (projectScopeKey: string | null) => void; +} + +export const useSidebarProjectScopeStore = create((set) => ({ + projectScopeKey: null, + setProjectScopeKey: (projectScopeKey) => set({ projectScopeKey }), +})); + +/** + * The project group the filter points at, or null when the filter is off or + * names a group that no longer exists (an unreachable scope must not silently + * capture new threads). + */ +export function useScopedProjectGroup( + groups: readonly T[], +): T | null { + const projectScopeKey = useSidebarProjectScopeStore((state) => state.projectScopeKey); + return useMemo( + () => + projectScopeKey === null + ? null + : (groups.find((group) => group.projectKey === projectScopeKey) ?? null), + [groups, projectScopeKey], + ); +} diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 5c0b17c3ee09..284b1ae83c27 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -63,7 +63,8 @@ The full command list and the current defaults are shown in **Settings** → **K always matches the build you are running. Use that rather than a copied list. Note that `chat.new` and `chat.newLocal` both create a thread through the same path. A new thread -inherits the project you were in, along with model and mode selections. Branch, worktree, and +inherits the project you were in — or, when no thread is open, the project the sidebar is filtered +to — along with model and mode selections. Branch, worktree, and environment mode always come from your configured defaults, not from the thread you were looking at. To keep a worktree, use the explicit "new thread in this worktree" action in the branch toolbar. The only difference between the two commands: with the current sidebar and more than one diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e813c8dfa8af..d00810de9714 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -12,6 +12,18 @@ If reordering is unavailable for one environment, update the T3 Code server runn environment. Older servers can still pin and unpin threads, but do not understand synced ordering; their pinned threads keep the default newest-first order below the ones you have arranged. +## Filtering by project + +The menu below the search box narrows the sidebar to a single project. While a project is +selected, **New thread** creates in that project instead of asking which one you want, so you can +pick a project once and keep working in it. Threads you start this way always appear in the list +you are looking at. + +The keyboard keeps both doors open. The **new thread in current project** shortcut starts a thread +beside the one you have open, even when the sidebar is narrowed to somewhere else — with nothing +open it uses the filtered project. The plain **new thread** shortcut still opens the project +chooser. Choose **All projects** to clear the filter. + ## Environment artwork Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in