diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 688a8a8ea791..d6cd3bcc9d28 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -126,7 +126,13 @@ export interface CommandPaletteView { export function enumerateCommandPaletteItems( items: ReadonlyArray, -): CommandPaletteActionItem[] { +): CommandPaletteActionItem[]; +export function enumerateCommandPaletteItems( + items: ReadonlyArray, +): Array; +export function enumerateCommandPaletteItems( + items: ReadonlyArray, +): Array { return items.map((item, index) => { const shortcutCommand = THREAD_JUMP_KEYBINDING_COMMANDS[index]; if (shortcutCommand) return { ...item, shortcutCommand }; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c5ec3f095167..994abd703b8c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -42,6 +42,7 @@ import { FolderPlusIcon, LinkIcon, MessageSquareIcon, + MonitorIcon, PaletteIcon, ServerIcon, SettingsIcon, @@ -165,6 +166,7 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, + listNewThreadProjectDestinations, } from "../sidebarProjectGrouping"; import type { Project } from "../types"; @@ -665,7 +667,11 @@ function OpenCommandPaletteDialog(props: { { kind: isLocal ? "local" : "remote", label: isPrimary - ? "Local" + ? resolveEnvironmentOptionLabel({ + isPrimary, + environmentId: environment.environmentId, + runtimeLabel: environment.label, + }) : isLocal ? `${environment.label} (Local)` : environment.label, @@ -1024,61 +1030,94 @@ function OpenCommandPaletteDialog(props: { const projectThreadItems = useMemo( () => enumerateCommandPaletteItems( - buildProjectActionItems({ - projects: pickerProjects, - valuePrefix: "new-thread-in", - searchTerms: (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const location = projectEnvironmentLocationById.get(project.environmentId); - return [ - ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? - []), - ...(location ? [location.label] : []), - ]; - }, - renderDescription: (project) => { - const location = projectEnvironmentLocationById.get(project.environmentId) ?? { - kind: "remote", + projectPickerEntries.map( + ({ group, targetProject }): CommandPaletteActionItem | CommandPaletteSubmenuItem => { + const locations = listNewThreadProjectDestinations(group, targetProject).map( + (member) => { + const location = projectEnvironmentLocationById.get(member.environmentId) ?? { + kind: "remote" as const, + label: member.environmentLabel ?? member.environmentId, + }; + return { location, member }; + }, + ); + const searchTerms = group.memberProjects.flatMap((member) => [ + member.title, + member.workspaceRoot, + member.environmentLabel ?? member.environmentId, + ]); + const icon = projectFavicon(targetProject); + + if (locations.length > 1) { + return { + kind: "submenu", + value: `new-thread-in:${targetProject.environmentId}:${targetProject.id}`, + searchTerms, + title: group.displayName, + description: `${locations.length} servers`, + icon, + addonIcon: , + groups: [ + { + value: `new-thread-in-servers:${group.projectKey}`, + label: "Run on", + items: locations.map(({ location, member }) => { + const LocationIcon = location.kind === "local" ? MonitorIcon : ServerIcon; + return { + kind: "action" as const, + value: `new-thread-in-server:${member.environmentId}:${member.id}`, + searchTerms: [location.label, member.workspaceRoot, member.title], + title: location.label, + description: member.workspaceRoot, + icon: , + run: async () => { + await handleNewThread(scopeProjectRef(member.environmentId, member.id)); + }, + }; + }), + }, + ], + }; + } + + const activeLocation = locations[0]?.location ?? { + kind: "remote" as const, label: "Remote", }; - return ( - - - {location.kind === "remote" ? ( - - ) : null} - {location.label} - - - {project.workspaceRoot} - - ); - }, - icon: projectFavicon, - runProject: async (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const ActiveLocationIcon = activeLocation.kind === "local" ? MonitorIcon : ServerIcon; const contextualRefBelongsToGroup = contextualProjectRef !== null && - group?.memberProjectRefs.some( + group.memberProjectRefs.some( (projectRef) => projectRef.environmentId === contextualProjectRef.environmentId && projectRef.projectId === contextualProjectRef.projectId, ); - await handleNewThread( - contextualRefBelongsToGroup - ? contextualProjectRef - : scopeProjectRef(project.environmentId, project.id), - ); + return { + kind: "action", + value: `new-thread-in:${targetProject.environmentId}:${targetProject.id}`, + searchTerms, + title: group.displayName, + description: ( + + + {activeLocation.label} + + {targetProject.workspaceRoot} + + ), + icon, + run: async () => { + await handleNewThread( + contextualRefBelongsToGroup + ? contextualProjectRef + : scopeProjectRef(targetProject.environmentId, targetProject.id), + ); + }, + }; }, - }), + ), ), - [ - contextualProjectRef, - handleNewThread, - pickerProjects, - projectEnvironmentLocationById, - projectGroupByTargetKey, - ], + [contextualProjectRef, handleNewThread, projectEnvironmentLocationById, projectPickerEntries], ); const allThreadItems = useMemo( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index ba75f2eaaf54..ab5b0661af7c 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -462,15 +462,15 @@ describe("isSidebarNestedLinkClick", () => { }); describe("shouldCreateNewThreadInCurrentProject", () => { - it("creates directly on shift+click in a multi-project setup", () => { + it("creates directly on shift+click with multiple destinations", () => { expect(shouldCreateNewThreadInCurrentProject(true, 2)).toBe(true); }); - it("opens the picker on a plain click in a multi-project setup", () => { + it("opens the picker on a plain click with multiple destinations", () => { expect(shouldCreateNewThreadInCurrentProject(false, 2)).toBe(false); }); - it("creates directly on any click with a single project", () => { + it("creates directly on any click with a single destination", () => { expect(shouldCreateNewThreadInCurrentProject(false, 1)).toBe(true); expect(shouldCreateNewThreadInCurrentProject(true, 1)).toBe(true); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 747fc07d3daf..34781de5447a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -300,14 +300,14 @@ 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 +// project, skipping the command palette's destination picker. With a single +// destination there is nothing to pick, so a plain click already creates // immediately and the modifier changes nothing. export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, - projectGroupCount: number, + destinationCount: number, ): boolean { - return shiftKey || projectGroupCount <= 1; + return shiftKey || destinationCount <= 1; } export function orderItemsByPreferredIds(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 971ead810f07..5ac3b14f79dd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -93,6 +93,7 @@ import { readLocalApi } from "../localApi"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, + countNewThreadDestinations, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -1868,6 +1869,10 @@ export default function Sidebar() { () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); + const newThreadDestinationCount = useMemo( + () => countNewThreadDestinations(projectGroups), + [projectGroups], + ); const serverConfigs = useAtomValue(environmentServerConfigsAtom); // Threads on non-primary environments (T3 Connect, hosted) resolve their // provider entry from their own environment's config: default instance ids @@ -3332,14 +3337,16 @@ export default function Sidebar() { // 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. + // uses. A grouped project that exists on multiple environments still has + // multiple destinations, so route it through the picker too. 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 destination: nothing to pick, create immediately. Shift+click + // creates directly in the current project even with several destinations, + // skipping the palette picker. + if ( + shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, newThreadDestinationCount) + ) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, @@ -3352,20 +3359,18 @@ export default function Sidebar() { if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); }, - [isMobile, newThreadContext, projectGroups.length, setOpenMobile], + [isMobile, newThreadContext, newThreadDestinationCount, setOpenMobile], ); - // 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. + // The button mirrors chat.new: with multiple destinations both route through + // the command palette picker, and with one destination both create + // immediately. The second tooltip line advertises shift+click and its + // keyboard twin chat.newLocal for direct create. const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + (newThreadDestinationCount <= 1 + ? shortcutLabelForCommand(keybindings, "chat.newLocal") + : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -3444,7 +3449,7 @@ export default function Sidebar() { /> - {projectGroups.length > 1 ? ( + {newThreadDestinationCount > 1 ? ( {newThreadShortcutLabel diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 9029f1204d36..dff7e410e4da 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -12,6 +12,8 @@ import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, + countNewThreadDestinations, + listNewThreadProjectDestinations, } from "./sidebarProjectGrouping"; import { orderItemsByPreferredIds } from "./components/Sidebar.logic"; import { legacyProjectCwdPreferenceKey } from "./uiStateStore"; @@ -63,7 +65,7 @@ describe("environment grouping", () => { expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey); }); - it("counts cross-environment copies as one new-thread project choice", () => { + it("counts each server copy as a new-thread destination", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ id: ProjectId.make("project-remote"), @@ -71,14 +73,18 @@ describe("environment grouping", () => { repositoryIdentity, }); - const projectGroupCount = buildSidebarProjectSnapshots({ + const groups = buildSidebarProjectSnapshots({ projects: [primary, remote], settings: defaultGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: () => null, - }).length; + }); - expect(projectGroupCount).toBe(1); + expect(groups).toHaveLength(1); + expect(countNewThreadDestinations(groups)).toBe(2); + expect( + listNewThreadProjectDestinations(groups[0]!, remote).map((project) => project.id), + ).toEqual([remote.id, primary.id]); }); it("keeps projects without repository identity physically scoped", () => { diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index e084e22c2cbb..372303300761 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -8,7 +8,10 @@ import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; import { selectProjectGroupingSettings } from "../logicalProject"; -import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import { + buildSidebarProjectSnapshots, + countNewThreadDestinations, +} from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; @@ -32,14 +35,16 @@ function ChatRouteGlobalShortcuts() { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const projectGroupCount = useMemo( + const newThreadDestinationCount = useMemo( () => - buildSidebarProjectSnapshots({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: () => null, - }).length, + countNewThreadDestinations( + buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }), + ), [primaryEnvironmentId, projectGroupingSettings, projects], ); const terminalOpen = useTerminalUiStateStore((state) => @@ -94,8 +99,8 @@ 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-destination setups) keep the immediate contextual create. + if (!legacySidebarEnabled && newThreadDestinationCount > 1) { openCommandPalette({ open: "new-thread-in" }); return; } @@ -164,7 +169,7 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, - projectGroupCount, + newThreadDestinationCount, routeThreadRef, selectedThreadKeysSize, legacySidebarEnabled, diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index be92fcaee849..aa57b68a68c8 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -31,6 +31,43 @@ export interface SidebarProjectPickerEntry { isPreferred: boolean; } +export function countNewThreadDestinations( + groups: ReadonlyArray>, +): number { + return groups.reduce( + (count, group) => + count + new Set(group.memberProjects.map((project) => project.environmentId)).size, + 0, + ); +} + +export function listNewThreadProjectDestinations( + group: Pick, + targetProject: Pick, +): SidebarProjectGroupMember[] { + const projectByEnvironmentId = new Map(); + for (const member of group.memberProjects) { + if ( + !projectByEnvironmentId.has(member.environmentId) || + (member.environmentId === targetProject.environmentId && member.id === targetProject.id) + ) { + projectByEnvironmentId.set(member.environmentId, member); + } + } + + const destinations = [...projectByEnvironmentId.values()]; + const targetIndex = destinations.findIndex( + (project) => project.environmentId === targetProject.environmentId, + ); + if (targetIndex <= 0) return destinations; + + return [ + destinations[targetIndex]!, + ...destinations.slice(0, targetIndex), + ...destinations.slice(targetIndex + 1), + ]; +} + export function buildPhysicalToLogicalProjectKeyMap(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings;