Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,13 @@ export function buildProjectActionItems(input: {
valuePrefix: string;
icon: (project: Project) => ReactNode;
runProject: (project: Project) => Promise<void>;
searchTerms?: (project: Project) => ReadonlyArray<string>;
shortcutCommand?: KeybindingCommand;
}): CommandPaletteActionItem[] {
return input.projects.map((project) => ({
kind: "action",
value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,
searchTerms: [project.title, project.workspaceRoot],
searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])],
title: project.title,
description: project.workspaceRoot,
icon: input.icon(project),
Expand Down
184 changes: 166 additions & 18 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ import { useEnvironmentQuery } from "../state/query";
import { sourceControlEnvironment } from "../state/sourceControl";
import { useAtomCommand } from "../state/use-atom-command";
import { useAtomQueryRunner } from "../state/use-atom-query-runner";
import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
import { useProjects, useThreadShells } from "../state/entities";
import {
resolveThreadActionProjectRef,
startNewThreadInProjectFromContext,
startNewThreadFromContext,
} from "../lib/chatThreadActions";
Expand All @@ -79,7 +80,7 @@ import {
} from "../lib/projectPaths";
import { onOpenCommandPalette } from "../commandPaletteBus";
import { isTerminalFocused } from "../lib/terminalFocus";
import { getLatestThreadForProject } from "../lib/threadSort";
import { getLatestThreadForProject, sortThreads } from "../lib/threadSort";
import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
Expand All @@ -106,6 +107,7 @@ import {
ITEM_ICON_CLASS,
RECENT_THREAD_LIMIT,
} from "./CommandPalette.logic";
import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic";
import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
import { CommandPaletteResults } from "./CommandPaletteResults";
import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";
Expand All @@ -128,6 +130,12 @@ import { stackedThreadToast, toastManager } from "./ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
import type { ChatComposerHandle } from "./chat/ChatComposer";
import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
import {
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
} from "../sidebarProjectGrouping";

const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];

Expand Down Expand Up @@ -487,10 +495,11 @@ function OpenCommandPaletteDialog(props: {
});
const { environments } = useEnvironments();
const desktopLocalBootstraps = useDesktopLocalBootstraps();
const primaryEnvironment = usePrimaryEnvironment();
const primaryEnvironmentId = usePrimaryEnvironmentId();
const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
useHandleNewThread();
const projects = useProjects();
const projectOrder = useUiStateStore((store) => store.projectOrder);
const threads = useThreadShells();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const providers = useAtomValue(primaryServerProvidersAtom);
Expand All @@ -504,7 +513,93 @@ function OpenCommandPaletteDialog(props: {
const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null);
const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false);
const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false);
const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
const projectGroupingSettings = useMemo(
() => selectProjectGroupingSettings(clientSettings),
[clientSettings],
);

const environmentLabelById = useMemo(
() =>
new Map(
environments.map((environment) => [environment.environmentId, environment.label] as const),
),
[environments],
);
const orderedProjects = useMemo(
() =>
orderItemsByPreferredIds({
items: projects,
preferredIds: projectOrder,
getId: getProjectOrderKey,
getPreferenceIds: (project) => [
getProjectOrderKey(project),
legacyProjectCwdPreferenceKey(project.workspaceRoot),
],
}),
[projectOrder, projects],
);
const unsortedProjectGroups = useMemo(
() =>
buildSidebarProjectSnapshots({
projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects,
settings: projectGroupingSettings,
primaryEnvironmentId,
resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,
Comment thread
cursor[bot] marked this conversation as resolved.
}),
[
clientSettings.sidebarProjectSortOrder,
environmentLabelById,
orderedProjects,
primaryEnvironmentId,
projectGroupingSettings,
projects,
],
);
const projectGroups = useMemo(
() =>
sortLogicalProjectsForSidebar(
unsortedProjectGroups,
threads,
clientSettings.sidebarProjectSortOrder,
),
[clientSettings.sidebarProjectSortOrder, threads, unsortedProjectGroups],
);
const contextualProjectRef = useMemo(
() =>
resolveThreadActionProjectRef({
activeDraftThread,
activeThread: activeThread ?? undefined,
defaultProjectRef,
handleNewThread,
}),
[activeDraftThread, activeThread, defaultProjectRef, handleNewThread],
);
const projectPickerEntries = useMemo(
() =>
buildSidebarProjectPickerEntries({
groups: projectGroups,
preferredProjectRef: contextualProjectRef,
}),
[contextualProjectRef, projectGroups],
);
const pickerProjects = useMemo(
() =>
projectPickerEntries.map(({ group, targetProject }) => ({
...targetProject,
title: group.displayName,
})),
[projectPickerEntries],
);
Comment thread
cursor[bot] marked this conversation as resolved.
const projectGroupByTargetKey = useMemo(
() =>
new Map(
projectPickerEntries.map(({ group, targetProject }) => [
`${targetProject.environmentId}:${targetProject.id}`,
group,
]),
),
[projectPickerEntries],
);

const addProjectEnvironmentOptions = useMemo(() => {
const options = environments.map((environment): AddProjectEnvironmentOption => {
Expand Down Expand Up @@ -648,11 +743,28 @@ function OpenCommandPaletteDialog(props: {

const openProjectFromSearch = useMemo(
() => async (project: (typeof projects)[number]) => {
const latestThread = getLatestThreadForProject(
threads.filter((thread) => thread.environmentId === project.environmentId),
project.id,
clientSettings.sidebarThreadSortOrder,
);
const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
const groupedProjectKeys = group
? new Set(
group.memberProjectRefs.map(
(projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
),
)
: null;
const latestThread = groupedProjectKeys
? (sortThreads(
threads.filter(
(thread) =>
thread.archivedAt === null &&
groupedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`),
),
clientSettings.sidebarThreadSortOrder,
)[0] ?? null)
: getLatestThreadForProject(
threads.filter((thread) => thread.environmentId === project.environmentId),
project.id,
clientSettings.sidebarThreadSortOrder,
);
if (latestThread) {
await navigate({
to: "/$environmentId/$threadId",
Expand All @@ -665,14 +777,26 @@ function OpenCommandPaletteDialog(props: {

await handleNewThread(scopeProjectRef(project.environmentId, project.id));
},
[handleNewThread, navigate, clientSettings.sidebarThreadSortOrder, threads],
[
clientSettings.sidebarThreadSortOrder,
handleNewThread,
navigate,
projectGroupByTargetKey,
threads,
],
);

const projectSearchItems = useMemo(
() =>
buildProjectActionItems({
projects,
projects: pickerProjects,
valuePrefix: "project",
searchTerms: (project) => {
const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
return (
group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? []
);
},
icon: (project) => (
<ProjectFavicon
environmentId={project.environmentId}
Expand All @@ -682,15 +806,21 @@ function OpenCommandPaletteDialog(props: {
),
runProject: openProjectFromSearch,
}),
[openProjectFromSearch, projects],
[openProjectFromSearch, pickerProjects, projectGroupByTargetKey],
);

const projectThreadItems = useMemo(
() =>
enumerateCommandPaletteItems(
buildProjectActionItems({
projects,
projects: pickerProjects,
valuePrefix: "new-thread-in",
searchTerms: (project) => {
const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
return (
group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? []
);
},
icon: (project) => (
<ProjectFavicon
environmentId={project.environmentId}
Expand All @@ -699,19 +829,37 @@ function OpenCommandPaletteDialog(props: {
/>
),
runProject: async (project) => {
const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
const contextualRefBelongsToGroup =
contextualProjectRef !== null &&
group?.memberProjectRefs.some(
(projectRef) =>
projectRef.environmentId === contextualProjectRef.environmentId &&
projectRef.projectId === contextualProjectRef.projectId,
);
await startNewThreadInProjectFromContext(
{
activeDraftThread,
activeThread: activeThread ?? undefined,
defaultProjectRef,
handleNewThread,
},
scopeProjectRef(project.environmentId, project.id),
contextualRefBelongsToGroup
? contextualProjectRef
: scopeProjectRef(project.environmentId, project.id),
);
},
}),
),
[activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects],
[
activeDraftThread,
activeThread,
contextualProjectRef,
defaultProjectRef,
handleNewThread,
pickerProjects,
projectGroupByTargetKey,
],
);

const allThreadItems = useMemo(
Expand Down Expand Up @@ -1018,9 +1166,9 @@ function OpenCommandPaletteDialog(props: {
const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];

if (projects.length > 0) {
const activeProjectTitle = currentProjectId
? (projectTitleById.get(currentProjectId) ?? null)
: null;
const activeProjectTitle =
projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ??
(currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null);

if (activeProjectTitle) {
actionItems.push({
Expand Down
21 changes: 14 additions & 7 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Un-settle thread"
onClick={handleUnsettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5"
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/SidebarV2.tsx:576

The settle/unsettle buttons lose their opaque background, so when a keyboard user tabs to one without hovering, focus-visible:opacity-100 reveals the button while the time/status label underneath stays visible (it's only hidden via group-hover/v2-row:opacity-0). Both texts render in the same space and overlap, becoming illegible. Restoring an opaque background or adding group-focus-within/v2-row:opacity-0 to the label would prevent the overlap. The same issue affects the equivalent buttons at lines 585 and 662.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/SidebarV2.tsx around line 576:

The settle/unsettle buttons lose their opaque background, so when a keyboard user tabs to one without hovering, `focus-visible:opacity-100` reveals the button while the time/status label underneath stays visible (it's only hidden via `group-hover/v2-row:opacity-0`). Both texts render in the same space and overlap, becoming illegible. Restoring an opaque background or adding `group-focus-within/v2-row:opacity-0` to the label would prevent the overlap. The same issue affects the equivalent buttons at lines 585 and 662.

>
<Undo2Icon className="size-3" />
</button>
Expand All @@ -582,7 +582,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5"
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
>
<CheckIcon className="size-3" />
</button>
Expand Down Expand Up @@ -659,7 +659,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5"
className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
>
<CheckIcon className="size-3" />
Settle
Expand Down Expand Up @@ -823,10 +823,16 @@ export default function SidebarV2() {
),
[projects],
);
const projectTitleByKey = useMemo(
const projectDisplayNameByKey = useMemo(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/SidebarV2.tsx:826

projectDisplayNameByKey is built only from group.memberProjects, which buildSidebarProjectSnapshots deduplicates down to one physical-project winner per group. But threads can belong to a discarded duplicate project ref that is absent from memberProjects yet present in memberProjectRefs. For those rows, the projectDisplayNameByKey.get(...) lookup returns undefined, so the row gets projectTitle={null} instead of the group display name it received before this change. The mapping should be built from group.memberProjectRefs (or the raw projects list) so every concrete ref resolves to the group's display name.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/SidebarV2.tsx around line 826:

`projectDisplayNameByKey` is built only from `group.memberProjects`, which `buildSidebarProjectSnapshots` deduplicates down to one physical-project winner per group. But threads can belong to a discarded duplicate project ref that is absent from `memberProjects` yet present in `memberProjectRefs`. For those rows, the `projectDisplayNameByKey.get(...)` lookup returns `undefined`, so the row gets `projectTitle={null}` instead of the group display name it received before this change. The mapping should be built from `group.memberProjectRefs` (or the raw `projects` list) so every concrete ref resolves to the group's display name.

() =>
new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])),
[projects],
new Map(
projectGroups.flatMap((group) =>
group.memberProjects.map(
(project) => [`${project.environmentId}:${project.id}`, group.displayName] as const,
),
),
),
[projectGroups],
Comment thread
cursor[bot] marked this conversation as resolved.
);

// now is quantized to the minute so effectiveSettled memoization doesn't
Expand Down Expand Up @@ -1768,7 +1774,8 @@ export default function SidebarV2() {
projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
}
projectTitle={
projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
projectDisplayNameByKey.get(`${thread.environmentId}:${thread.projectId}`) ??
null
}
providerEntryByInstanceId={providerEntryByInstanceId}
onThreadClick={handleThreadClick}
Expand Down
Loading
Loading