diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac8416..9fdf743b814 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -201,6 +201,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); + assert.equal(defaultsByCommand.get("chat.newEnvironment"), "mod+shift+n"); + assert.isFalse(defaultsByCommand.has("chat.newLocal")); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); }), @@ -301,6 +303,55 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("migrates the previous new-local default to the environment picker", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { + key: "mod+shift+n", + command: "chat.newLocal", + when: "!terminalFocus", + }, + { + key: "mod+shift+l", + command: "chat.newLocal", + when: "!terminalFocus", + }, + ]); + + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + yield* keybindings.syncDefaultKeybindingsOnStartup; + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + assert.isTrue( + persisted.some( + (entry) => + entry.command === "chat.newEnvironment" && + entry.key === "mod+shift+n" && + entry.when === "!terminalFocus", + ), + ); + assert.isFalse( + persisted.some( + (entry) => + entry.command === "chat.newLocal" && + entry.key === "mod+shift+n" && + entry.when === "!terminalFocus", + ), + ); + assert.isTrue( + persisted.some( + (entry) => + entry.command === "chat.newLocal" && + entry.key === "mod+shift+l" && + entry.when === "!terminalFocus", + ), + ); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("skips conflicting default keybindings on startup and logs a detailed warning", () => { const messages: string[] = []; const logger = Logger.make(({ message }) => { diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 18a78fe3623..6cb4a53237a 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -109,6 +109,17 @@ function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): bool ); } +const LEGACY_NEW_LOCAL_DEFAULT: KeybindingRule = { + key: "mod+shift+n", + command: "chat.newLocal", + when: "!terminalFocus", +}; +const NEW_ENVIRONMENT_DEFAULT: KeybindingRule = { + key: "mod+shift+n", + command: "chat.newEnvironment", + when: "!terminalFocus", +}; + function keybindingShortcutContext(rule: KeybindingRule): string | null { const parsed = parseKeybindingShortcut(rule.key); if (!parsed) return null; @@ -493,7 +504,16 @@ const make = Effect.gen(function* () { yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); return; } - const customConfig = runtimeConfig.keybindings; + const shouldMigrateNewEnvironmentDefault = + !runtimeConfig.keybindings.some((entry) => entry.command === "chat.newEnvironment") && + runtimeConfig.keybindings.some((entry) => + isSameKeybindingRule(entry, LEGACY_NEW_LOCAL_DEFAULT), + ); + const customConfig = shouldMigrateNewEnvironmentDefault + ? runtimeConfig.keybindings.map((entry) => + isSameKeybindingRule(entry, LEGACY_NEW_LOCAL_DEFAULT) ? NEW_ENVIRONMENT_DEFAULT : entry, + ) + : runtimeConfig.keybindings; const existingCommands = new Set(customConfig.map((entry) => entry.command)); const missingDefaults: KeybindingRule[] = []; const shortcutConflictWarnings: Array<{ @@ -530,7 +550,7 @@ const make = Effect.gen(function* () { reason: "shortcut context already used by existing rule", }); } - if (missingDefaults.length === 0) { + if (missingDefaults.length === 0 && !shouldMigrateNewEnvironmentDefault) { yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); return; } diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992..a61ea02c346 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -3,7 +3,7 @@ const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; export interface CommandPaletteOpenDetail { - readonly open?: "add-project" | "new-thread-in"; + readonly open?: "add-project" | "new-thread-in" | "new-thread-on"; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 902b7e87773..c476305f903 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -5,8 +5,38 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + resolveNewThreadOnIntent, type CommandPaletteGroup, } from "./CommandPalette.logic"; +import { reduceCommandPaletteUiState } from "./CommandPalette"; + +describe("resolveNewThreadOnIntent", () => { + it("distinguishes loading from a loaded-empty environment list", () => { + expect( + resolveNewThreadOnIntent({ isActive: false, isLoaded: false, environmentItemCount: 0 }), + ).toBe("ignore"); + expect( + resolveNewThreadOnIntent({ isActive: true, isLoaded: false, environmentItemCount: 0 }), + ).toBe("defer"); + expect( + resolveNewThreadOnIntent({ isActive: true, isLoaded: true, environmentItemCount: 0 }), + ).toBe("clear"); + expect( + resolveNewThreadOnIntent({ isActive: true, isLoaded: true, environmentItemCount: 1 }), + ).toBe("open"); + }); +}); + +describe("reduceCommandPaletteUiState", () => { + it("closes and clears a deferred open intent when no targets are available", () => { + expect( + reduceCommandPaletteUiState( + { open: true, openIntent: { kind: "new-thread-on" } }, + { _tag: "SetOpen", open: false }, + ), + ).toEqual({ open: false, openIntent: null }); + }); +}); describe("enumerateCommandPaletteItems", () => { it("assigns positional jump shortcuts to the first nine displayed items", () => { diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f69c38e1a0f..fb49a1f04b6 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,6 +15,16 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; export const ADDON_ICON_CLASS = "size-4"; +export function resolveNewThreadOnIntent(input: { + isActive: boolean; + isLoaded: boolean; + environmentItemCount: number; +}): "ignore" | "defer" | "clear" | "open" { + if (!input.isActive) return "ignore"; + if (!input.isLoaded) return "defer"; + return input.environmentItemCount > 0 ? "open" : "clear"; +} + export interface CommandPaletteItem { readonly kind: "action" | "submenu"; readonly value: string; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..0f4acfeb097 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -22,11 +22,13 @@ import { ArrowDownIcon, ArrowLeftIcon, ArrowUpIcon, + CloudIcon, CornerLeftUpIcon, FolderIcon, FolderPlusIcon, LinkIcon, MessageSquareIcon, + MonitorIcon, SettingsIcon, SquarePenIcon, } from "lucide-react"; @@ -34,6 +36,7 @@ import { useCallback, useDeferredValue, useEffect, + useEffectEvent, useLayoutEffect, useMemo, useReducer, @@ -57,7 +60,11 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useThreadShells, +} from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -102,6 +109,7 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + resolveNewThreadOnIntent, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -158,6 +166,19 @@ function getEnvironmentBrowsePlatform(os: string | null | undefined): string { return typeof navigator === "undefined" ? "" : navigator.platform; } +function renderProjectActionIcon(project: { + environmentId: EnvironmentId; + workspaceRoot: string; +}): ReactNode { + return ( + + ); +} + interface AddProjectEnvironmentOption { readonly environmentId: EnvironmentId; readonly label: string; @@ -340,7 +361,7 @@ function errorMessage(error: unknown): string { } interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; + readonly kind: "add-project" | "new-thread-in" | "new-thread-on"; } interface CommandPaletteUiState { @@ -353,9 +374,10 @@ type CommandPaletteUiAction = | { readonly _tag: "Toggle" } | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } + | { readonly _tag: "OpenNewThreadOn" } | { readonly _tag: "ClearOpenIntent" }; -function reduceCommandPaletteUiState( +export function reduceCommandPaletteUiState( state: CommandPaletteUiState, action: CommandPaletteUiAction, ): CommandPaletteUiState { @@ -371,6 +393,8 @@ function reduceCommandPaletteUiState( return { open: true, openIntent: { kind: "add-project" } }; case "OpenNewThreadIn": return { open: true, openIntent: { kind: "new-thread-in" } }; + case "OpenNewThreadOn": + return { open: true, openIntent: { kind: "new-thread-on" } }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; } @@ -385,6 +409,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); + const openNewThreadOn = useCallback(() => dispatch({ _tag: "OpenNewThreadOn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const composerHandleRef = useRef(null); @@ -424,13 +449,15 @@ export function CommandPalette({ children }: { children: ReactNode }) { onOpenCommandPalette((detail) => { if (detail.open === "new-thread-in") { openNewThreadIn(); + } else if (detail.open === "new-thread-on") { + openNewThreadOn(); } else if (detail.open === "add-project") { openAddProject(); } else { setOpen(true); } }), - [openAddProject, openNewThreadIn, setOpen], + [openAddProject, openNewThreadIn, openNewThreadOn, setOpen], ); return ( @@ -495,6 +522,7 @@ function OpenCommandPaletteDialog(props: { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); + const environmentShellsLoaded = useAllEnvironmentShellsBootstrapped(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -620,6 +648,13 @@ function OpenCommandPaletteDialog(props: { return options; }, [environments]); + const newThreadEnvironmentOptions = useMemo( + () => + [...addProjectEnvironmentOptions].sort((left, right) => + left.label.localeCompare(right.label), + ), + [addProjectEnvironmentOptions], + ); const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; const wslAddProjectEnvironmentOption = useMemo( () => @@ -793,18 +828,93 @@ function OpenCommandPaletteDialog(props: { group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] ); }, - icon: (project) => ( - - ), + icon: renderProjectActionIcon, runProject: openProjectFromSearch, }), [openProjectFromSearch, pickerProjects, projectGroupByTargetKey], ); + const newThreadEnvironmentItems = useMemo((): CommandPaletteSubmenuItem[] => { + const orderedEnvironmentOptions = currentProjectEnvironmentId + ? [ + ...newThreadEnvironmentOptions.filter( + (option) => option.environmentId === currentProjectEnvironmentId, + ), + ...newThreadEnvironmentOptions.filter( + (option) => option.environmentId !== currentProjectEnvironmentId, + ), + ] + : newThreadEnvironmentOptions; + + return orderedEnvironmentOptions.flatMap((option): CommandPaletteSubmenuItem[] => { + const environmentEntries = buildSidebarProjectPickerEntries({ + groups: projectGroups, + preferredProjectRef: contextualProjectRef, + targetEnvironmentId: option.environmentId, + }); + if (environmentEntries.length === 0) return []; + + const groupByTargetKey = new Map( + environmentEntries.map(({ group, targetProject }) => [ + `${targetProject.environmentId}:${targetProject.id}`, + group, + ]), + ); + const environmentProjects = environmentEntries.map(({ group, targetProject }) => ({ + ...targetProject, + title: group.displayName, + })); + const projectItems = enumerateCommandPaletteItems( + buildProjectActionItems({ + projects: environmentProjects, + valuePrefix: `new-thread-on:${option.environmentId}`, + searchTerms: (project) => { + const group = groupByTargetKey.get(`${project.environmentId}:${project.id}`); + return ( + group?.memberProjects + .filter((member) => member.environmentId === option.environmentId) + .flatMap((member) => [member.title, member.workspaceRoot]) ?? [] + ); + }, + icon: renderProjectActionIcon, + runProject: async (project) => { + await handleNewThread(scopeProjectRef(project.environmentId, project.id)); + }, + }), + ); + const projectCount = projectItems.length; + const EnvironmentIcon = option.isPrimary ? MonitorIcon : CloudIcon; + + return [ + { + kind: "submenu", + value: `new-thread-on:${option.environmentId}`, + searchTerms: [ + option.label, + option.isPrimary ? "local this device" : "remote environment machine", + ], + title: option.label, + description: `${String(projectCount)} ${projectCount === 1 ? "project" : "projects"}`, + icon: , + addonIcon: , + groups: [ + { + value: `new-thread-on-projects:${option.environmentId}`, + label: "Projects", + items: projectItems, + }, + ], + }, + ]; + }); + }, [ + contextualProjectRef, + currentProjectEnvironmentId, + handleNewThread, + newThreadEnvironmentOptions, + projectGroups, + ]); + const projectThreadItems = useMemo( () => enumerateCommandPaletteItems( @@ -817,13 +927,7 @@ function OpenCommandPaletteDialog(props: { group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] ); }, - icon: (project) => ( - - ), + icon: renderProjectActionIcon, runProject: async (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); const contextualRefBelongsToGroup = @@ -1145,6 +1249,39 @@ function OpenCommandPaletteDialog(props: { projectThreadItems, ]); + const openNewThreadOnFlow = useEffectEvent(() => { + clearOpenIntent(); + setAddProjectCloneFlow(null); + setViewStack([]); + setQuery(""); + pushPaletteView({ + addonIcon: , + groups: [ + { + value: "environments", + label: "Run on", + items: newThreadEnvironmentItems, + }, + ], + }); + }); + + useLayoutEffect(() => { + const resolution = resolveNewThreadOnIntent({ + isActive: openIntent?.kind === "new-thread-on", + isLoaded: environmentShellsLoaded, + environmentItemCount: newThreadEnvironmentItems.length, + }); + if (resolution === "clear") { + setOpen(false); + return; + } + if (resolution !== "open") { + return; + } + openNewThreadOnFlow(); + }, [environmentShellsLoaded, newThreadEnvironmentItems.length, openIntent, setOpen]); + const actionItems: Array = []; if (projects.length > 0) { @@ -1175,6 +1312,33 @@ function OpenCommandPaletteDialog(props: { }); } + if (newThreadEnvironmentItems.length > 0) { + actionItems.push({ + kind: "submenu", + value: "action:new-thread-on", + searchTerms: [ + "new thread", + "environment", + "remote", + "machine", + "project", + "pick", + "choose", + ], + title: "New thread on...", + icon: , + addonIcon: , + shortcutCommand: "chat.newEnvironment", + groups: [ + { + value: "environments", + label: "Run on", + items: newThreadEnvironmentItems, + }, + ], + }); + } + actionItems.push({ kind: "submenu", value: "action:new-thread-in", diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 17d86ca0912..52cad516b36 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -318,6 +318,43 @@ describe("environment grouping", () => { expect(entries[1]?.group.displayName).toBe("separate"); }); + it("builds environment-scoped picker entries without falling back to another machine", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const primaryOnly = makeProject({ + id: ProjectId.make("project-primary-only"), + title: "primary-only", + workspaceRoot: "/tmp/primary-only", + }); + const groups = buildSidebarProjectSnapshots({ + projects: [primaryOnly, primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: { + environmentId: primaryEnvironmentId, + projectId: primary.id, + }, + targetEnvironmentId: remoteEnvironmentId, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.group.projectKey).toBe(repositoryIdentity.canonicalKey); + expect(entries[0]?.targetProject).toMatchObject({ + environmentId: remoteEnvironmentId, + id: remote.id, + }); + expect(entries[0]?.isPreferred).toBe(false); + }); + it("keeps manual project order when building grouped sidebar entries", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..c0722722440 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -124,7 +124,7 @@ const DEFAULT_BINDINGS = compile([ whenAst: whenNot(whenIdentifier("terminalFocus")), }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, - { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, + { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newEnvironment" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, @@ -318,6 +318,10 @@ describe("shortcutLabelForCommand", () => { "⌘B", ); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.newEnvironment", "Linux"), + "Ctrl+Shift+N", + ); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "rightPanel.toggle", "MacIntel"), @@ -472,15 +476,33 @@ describe("chat/editor shortcuts", () => { }); it("matches chat.newLocal shortcut", () => { + const localBindings = compile([ + { shortcut: modShortcut("l", { shiftKey: true }), command: "chat.newLocal" }, + ]); assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + isChatNewLocalShortcut(event({ key: "l", metaKey: true, shiftKey: true }), localBindings, { platform: "MacIntel", }), ); assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + isChatNewLocalShortcut(event({ key: "l", ctrlKey: true, shiftKey: true }), localBindings, { + platform: "Linux", + }), + ); + }); + + it("resolves the environment-aware new-thread shortcut", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + "chat.newEnvironment", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), + "chat.newEnvironment", ); }); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d3cf003d99c..cbd4b5195d7 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -77,6 +77,13 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "chat.newEnvironment") { + event.preventDefault(); + event.stopPropagation(); + openCommandPalette({ open: "new-thread-on" }); + return; + } + if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 32299b565f4..7588f32f4d4 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -222,31 +222,42 @@ export function buildSidebarProjectSnapshots(input: { export function buildSidebarProjectPickerEntries(input: { groups: ReadonlyArray; preferredProjectRef: ScopedProjectRef | null; + targetEnvironmentId?: EnvironmentId; }) { const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { + const environmentMembers = + input.targetEnvironmentId === undefined + ? group.memberProjects + : group.memberProjects.filter( + (project) => project.environmentId === input.targetEnvironmentId, + ); + if (environmentMembers.length === 0) return []; + const isPreferred = input.preferredProjectRef ? group.memberProjectRefs.some( (projectRef) => projectRef.environmentId === input.preferredProjectRef?.environmentId && - projectRef.projectId === input.preferredProjectRef.projectId, + projectRef.projectId === input.preferredProjectRef.projectId && + (input.targetEnvironmentId === undefined || + projectRef.environmentId === input.targetEnvironmentId), ) : false; const preferredProject = isPreferred - ? (group.memberProjects.find( + ? (environmentMembers.find( (project) => project.environmentId === input.preferredProjectRef?.environmentId && project.id === input.preferredProjectRef?.projectId, ) ?? - group.memberProjects.find( + environmentMembers.find( (project) => project.environmentId === input.preferredProjectRef?.environmentId, )) : null; const targetProject = preferredProject ?? - group.memberProjects.find( + environmentMembers.find( (project) => project.environmentId === group.environmentId && project.id === group.id, ) ?? - group.memberProjects[0]; + environmentMembers[0]; if (!targetProject) return []; return [{ group, targetProject, isPreferred }]; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 33ecd38039f..e6f24b9a352 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -65,6 +65,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); + const parsedEnvironment = yield* decode(KeybindingRule, { + key: "mod+shift+n", + command: "chat.newEnvironment", + }); + assert.strictEqual(parsedEnvironment.command, "chat.newEnvironment"); + const parsedModelPickerToggle = yield* decode(KeybindingRule, { key: "mod+shift+m", command: "modelPicker.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..e9b47167f6c 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -64,6 +64,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.resetZoom", "commandPalette.toggle", "chat.new", + "chat.newEnvironment", "chat.newLocal", "editor.openFavorite", ...MODEL_PICKER_KEYBINDING_COMMANDS, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..bfca38bda7e 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -37,7 +37,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, - { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+shift+n", command: "chat.newEnvironment", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" },