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" },