Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,13 @@ export interface CommandPaletteView {

export function enumerateCommandPaletteItems(
items: ReadonlyArray<CommandPaletteActionItem>,
): CommandPaletteActionItem[] {
): CommandPaletteActionItem[];
export function enumerateCommandPaletteItems(
items: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>,
): Array<CommandPaletteActionItem | CommandPaletteSubmenuItem>;
export function enumerateCommandPaletteItems(
items: ReadonlyArray<CommandPaletteActionItem | CommandPaletteSubmenuItem>,
): Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> {
return items.map((item, index) => {
const shortcutCommand = THREAD_JUMP_KEYBINDING_COMMANDS[index];
if (shortcutCommand) return { ...item, shortcutCommand };
Expand Down
131 changes: 85 additions & 46 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
FolderPlusIcon,
LinkIcon,
MessageSquareIcon,
MonitorIcon,
PaletteIcon,
ServerIcon,
SettingsIcon,
Expand Down Expand Up @@ -165,6 +166,7 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"
import {
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
listNewThreadProjectDestinations,
} from "../sidebarProjectGrouping";
import type { Project } from "../types";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`,
Comment thread
StiensWout marked this conversation as resolved.
icon,
addonIcon: <ServerIcon className={ADDON_ICON_CLASS} />,
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: <LocationIcon className={ITEM_ICON_CLASS} />,
run: async () => {
await handleNewThread(scopeProjectRef(member.environmentId, member.id));
},
};
}),
Comment thread
cursor[bot] marked this conversation as resolved.
},
],
};
}

const activeLocation = locations[0]?.location ?? {
kind: "remote" as const,
label: "Remote",
};
return (
<span className="flex min-w-0 items-center gap-1">
<span className="inline-flex min-w-0 items-center gap-1">
{location.kind === "remote" ? (
<ServerIcon aria-hidden className={COMMAND_PALETTE_META_ICON_CLASS} />
) : null}
<span className="truncate">{location.label}</span>
</span>
<CommandPaletteMetaDot />
<span className="truncate">{project.workspaceRoot}</span>
</span>
);
},
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: (
<span className="flex min-w-0 items-center gap-1">
<ActiveLocationIcon aria-hidden className={COMMAND_PALETTE_META_ICON_CLASS} />
<span className="truncate">{activeLocation.label}</span>
<CommandPaletteMetaDot />
<span className="truncate">{targetProject.workspaceRoot}</span>
</span>
),
icon,
run: async () => {
await handleNewThread(
contextualRefBelongsToGroup
? contextualProjectRef
: scopeProjectRef(targetProject.environmentId, targetProject.id),
);
},
};
},
}),
),
),
[
contextualProjectRef,
handleNewThread,
pickerProjects,
projectEnvironmentLocationById,
projectGroupByTargetKey,
],
[contextualProjectRef, handleNewThread, projectEnvironmentLocationById, projectPickerEntries],
);

const allThreadItems = useMemo(
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TItem, TId>(input: {
Expand Down
39 changes: 22 additions & 17 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
<>
Expand Down Expand Up @@ -3444,7 +3449,7 @@ export default function Sidebar() {
/>
</TooltipTrigger>
<TooltipPopup side="right">
{projectGroups.length > 1 ? (
{newThreadDestinationCount > 1 ? (
<span className="flex flex-col gap-0.5">
<span>
{newThreadShortcutLabel
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/environmentGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
buildPhysicalToLogicalProjectKeyMap,
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
countNewThreadDestinations,
listNewThreadProjectDestinations,
} from "./sidebarProjectGrouping";
import { orderItemsByPreferredIds } from "./components/Sidebar.logic";
import { legacyProjectCwdPreferenceKey } from "./uiStateStore";
Expand Down Expand Up @@ -63,22 +65,26 @@ 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"),
environmentId: remoteEnvironmentId,
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", () => {
Expand Down
27 changes: 16 additions & 11 deletions apps/web/src/routes/_chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) =>
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -164,7 +169,7 @@ function ChatRouteGlobalShortcuts() {
keybindings,
defaultProjectRef,
previewOpen,
projectGroupCount,
newThreadDestinationCount,
routeThreadRef,
selectedThreadKeysSize,
legacySidebarEnabled,
Expand Down
Loading
Loading