Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c279153
fix: open command palette instead of custom dialog for new thread pic…
UtkarshUsername Jul 22, 2026
4f9bed9
feat: open command palette with 'New thread in...' submenu pre-expanded
UtkarshUsername Jul 22, 2026
937e9e2
fix: close mobile sidebar when opening command palette for new thread
UtkarshUsername Jul 22, 2026
dfaa44d
fix: prevent duplicate project picker submenus in command palette
UtkarshUsername Jul 22, 2026
2d446b2
fix: reset submenu stack before pushing new-thread-in project picker
UtkarshUsername Jul 22, 2026
c646680
fix: reset add-project clone flow when opening new-thread-in picker
UtkarshUsername Jul 22, 2026
11472b9
Merge branch 'main' into fix/sidebarv2-new-thread-command-palette
UtkarshUsername Jul 22, 2026
f8e6b3f
Merge branch 'main' into fix/sidebarv2-new-thread-command-palette
UtkarshUsername Jul 22, 2026
022a670
refactor: consolidate command palette open onto event bus, remove Rea…
UtkarshUsername Jul 22, 2026
b71f8c6
Merge branch 'main' into fix/sidebarv2-new-thread-command-palette
UtkarshUsername Jul 22, 2026
c799eb3
fix: prioritize current project in new-thread-in submenu
UtkarshUsername Jul 22, 2026
14d1907
Merge branch 'main' into fix/sidebarv2-new-thread-command-palette
UtkarshUsername Jul 22, 2026
e81e5b1
Merge branch 'main' into fix/sidebarv2-new-thread-command-palette
UtkarshUsername Jul 22, 2026
fccf504
fix: match project priority by both environment and project id
UtkarshUsername Jul 22, 2026
782dccf
Merge branch 'main' into fix/sidebarv2-new-thread-command-palette
UtkarshUsername Jul 22, 2026
230bd28
Merge branch 'main' into fix/sidebarv2-new-thread-command-palette
UtkarshUsername Jul 22, 2026
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
23 changes: 23 additions & 0 deletions apps/web/src/commandPaletteBus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Tiny event bus allowing components (e.g. SidebarV2) to programmatically
// open the command palette without owning its React state.
const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette";

export interface CommandPaletteOpenDetail {
readonly open?: "new-thread-in";
}

export function openCommandPalette(detail?: CommandPaletteOpenDetail): void {
window.dispatchEvent(
new CustomEvent(COMMAND_PALETTE_OPEN_EVENT, detail ? { detail } : undefined),
);
}

export function onOpenCommandPalette(
listener: (detail: CommandPaletteOpenDetail) => void,
): () => void {
const handler = (event: Event) => {
listener((event as CustomEvent<CommandPaletteOpenDetail>).detail ?? {});
};
window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, handler);
return () => window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, handler);
}
31 changes: 30 additions & 1 deletion apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
isUnsupportedWindowsProjectPath,
resolveProjectPathForDispatch,
} from "../lib/projectPaths";
import { onOpenCommandPalette } from "../commandPaletteBus";
import { isTerminalFocused } from "../lib/terminalFocus";
import { getLatestThreadForProject } from "../lib/threadSort";
import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";
Expand Down Expand Up @@ -335,7 +336,7 @@ function errorMessage(error: unknown): string {
}

interface CommandPaletteOpenIntent {
readonly kind: "add-project";
readonly kind: "add-project" | "new-thread-in";
}

interface CommandPaletteUiState {
Expand All @@ -347,6 +348,7 @@ type CommandPaletteUiAction =
| { readonly _tag: "SetOpen"; readonly open: boolean }
| { readonly _tag: "Toggle" }
| { readonly _tag: "OpenAddProject" }
| { readonly _tag: "OpenNewThreadIn" }
| { readonly _tag: "ClearOpenIntent" };

function reduceCommandPaletteUiState(
Expand All @@ -363,6 +365,8 @@ function reduceCommandPaletteUiState(
return { open: !state.open, openIntent: null };
case "OpenAddProject":
return { open: true, openIntent: { kind: "add-project" } };
case "OpenNewThreadIn":
return { open: true, openIntent: { kind: "new-thread-in" } };
case "ClearOpenIntent":
return state.openIntent ? { ...state, openIntent: null } : state;
}
Expand All @@ -376,6 +380,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {
const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []);
const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []);
const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []);
const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);
const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const composerHandleRef = useRef<ChatComposerHandle | null>(null);
Expand Down Expand Up @@ -410,6 +415,18 @@ export function CommandPalette({ children }: { children: ReactNode }) {
return () => window.removeEventListener("keydown", onKeyDown);
}, [keybindings, terminalOpen, toggleOpen]);

useEffect(
() =>
onOpenCommandPalette((detail) => {
if (detail.open === "new-thread-in") {
openNewThreadIn();
} else {
setOpen(true);
}
}),
[openNewThreadIn, setOpen],
);

return (
<OpenAddProjectCommandPaletteProvider openAddProject={openAddProject}>
<ComposerHandleContext value={composerHandleRef}>
Expand Down Expand Up @@ -960,6 +977,18 @@ function OpenCommandPaletteDialog(props: {
openAddProjectFlow();
}, [clearOpenIntent, openAddProjectFlow, openIntent]);

useLayoutEffect(() => {
if (openIntent?.kind !== "new-thread-in" || projectThreadItems.length === 0) {
return;
}
clearOpenIntent();
if (currentView !== null) return;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
pushPaletteView({
addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,
groups: [{ value: "projects", label: "Projects", items: projectThreadItems }],
});
}, [clearOpenIntent, currentView, openIntent, projectThreadItems]);

const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];

if (projects.length > 0) {
Expand Down
150 changes: 6 additions & 144 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,8 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
import { useThreadActions } from "../hooks/useThreadActions";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
import { onOpenNewThreadPicker } from "../newThreadPickerBus";
import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog";
import {
resolveThreadActionProjectRef,
startNewThreadFromContext,
startNewThreadInProjectFromContext,
} from "../lib/chatThreadActions";
import { openCommandPalette } from "../commandPaletteBus";
import { startNewThreadFromContext } from "../lib/chatThreadActions";
import { useClientSettings } from "../hooks/useSettings";
import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
import { useProjects, useThreadShells } from "../state/entities";
Expand Down Expand Up @@ -1309,12 +1304,8 @@ export default function SidebarV2() {

// 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 chevron menu is the explicit project picker the flat list no
// longer gets from per-project headers.
const [newThreadPickerOpen, setNewThreadPickerOpen] = useState(false);
// chat.new (mod+shift+o / mod+n) is handled by the _chat route layout; in
// v2 with multiple projects it opens this picker via the event bus.
useEffect(() => onOpenNewThreadPicker(() => setNewThreadPickerOpen(true)), []);
// uses. The command palette already offers a "New thread in..." submenu
// for multi-project setups.
const handleNewThreadClick = useCallback(() => {
// One project: nothing to pick, create immediately.
if (projects.length <= 1) {
Expand All @@ -1327,57 +1318,9 @@ export default function SidebarV2() {
});
return;
}
setNewThreadPickerOpen(true);
if (isMobile) setOpenMobile(false);
openCommandPalette({ open: "new-thread-in" });
}, [isMobile, newThreadContext, projects.length, setOpenMobile]);
const createThreadInProject = useCallback(
(environmentId: (typeof projects)[number]["environmentId"], projectId: string) => {
setNewThreadPickerOpen(false);
if (isMobile) setOpenMobile(false);
const project = projects.find(
(candidate) => candidate.environmentId === environmentId && candidate.id === projectId,
);
if (!project) return;
void startNewThreadInProjectFromContext(
{
activeDraftThread: newThreadContext.activeDraftThread,
activeThread: newThreadContext.activeThread ?? undefined,
defaultProjectRef: newThreadContext.defaultProjectRef,
handleNewThread: newThreadContext.handleNewThread,
},
scopeProjectRef(project.environmentId, project.id),
);
},
[isMobile, newThreadContext, projects, setOpenMobile],
);
const contextualProjectRef = resolveThreadActionProjectRef({
activeDraftThread: newThreadContext.activeDraftThread,
activeThread: newThreadContext.activeThread ?? undefined,
defaultProjectRef: newThreadContext.defaultProjectRef,
handleNewThread: newThreadContext.handleNewThread,
});
const newThreadTargetRef = scopedProject
? scopeProjectRef(scopedProject.environmentId, scopedProject.id)
: contextualProjectRef;
const newThreadTargetProject = newThreadTargetRef
? (projects.find(
(project) =>
project.environmentId === newThreadTargetRef.environmentId &&
project.id === newThreadTargetRef.projectId,
) ?? null)
: null;
// Picker order: the contextual default first (preselected), everything else
// after — the common case is Enter/click on the top row.
const newThreadPickerProjects = useMemo(() => {
if (!newThreadTargetProject) return projects;
return [
newThreadTargetProject,
...projects.filter(
(project) =>
project.environmentId !== newThreadTargetProject.environmentId ||
project.id !== newThreadTargetProject.id,
),
];
}, [newThreadTargetProject, projects]);

const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle");
// Same resolution as v1: prefer the local-thread binding, fall back to
Expand Down Expand Up @@ -1621,87 +1564,6 @@ export default function SidebarV2() {
</SidebarContent>
<SidebarSeparator />
<SidebarChromeFooter />
<Dialog open={newThreadPickerOpen} onOpenChange={setNewThreadPickerOpen}>
<DialogPopup className="max-w-sm p-0">
<DialogHeader className="px-4 pb-2 pt-4">
<DialogTitle className="text-sm">New thread in…</DialogTitle>
</DialogHeader>
<div
className="flex flex-col gap-0.5 px-2 pb-3"
role="listbox"
aria-label="Choose a project for the new thread"
onKeyDown={(event) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Home" &&
event.key !== "End"
) {
return;
}
const container = event.currentTarget;
const options = [...container.querySelectorAll<HTMLButtonElement>("button")];
if (options.length === 0) return;
const currentIndex = options.findIndex((option) => option === document.activeElement);
const nextIndex =
event.key === "Home"
? 0
: event.key === "End"
? options.length - 1
: event.key === "ArrowDown"
? (currentIndex + 1) % options.length
: (currentIndex - 1 + options.length) % options.length;
event.preventDefault();
options[nextIndex]?.focus();
}}
>
{newThreadPickerProjects.map((project, index) => {
const isDefault = index === 0 && newThreadTargetProject !== null;
return (
<button
key={`${project.environmentId}:${project.id}`}
type="button"
autoFocus={isDefault}
onClick={() => createThreadInProject(project.environmentId, project.id)}
className={cn(
"flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
"hover:bg-accent focus:bg-accent focus:outline-none",
isDefault && "bg-accent/50",
)}
>
<ProjectFavicon
environmentId={project.environmentId}
cwd={project.workspaceRoot}
className="size-4"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-foreground">
{project.title}
</span>
<span className="block truncate font-mono text-[10px] text-muted-foreground/60">
{project.workspaceRoot}
</span>
</span>
{isDefault ? (
<Kbd className="h-4 shrink-0 rounded-sm px-1.5 text-[10px]">↵</Kbd>
) : null}
</button>
);
})}
<button
type="button"
onClick={() => {
setNewThreadPickerOpen(false);
openAddProjectCommandPalette();
}}
className="mt-1 flex items-center gap-2.5 rounded-lg border border-dashed border-border px-2.5 py-2 text-left text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:bg-accent focus:text-foreground focus:outline-none"
>
<PlusIcon className="size-4" />
Add project
</button>
</div>
</DialogPopup>
</Dialog>
</>
);
}
6 changes: 3 additions & 3 deletions apps/web/src/routes/_chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEffect } from "react";

import { isCommandPaletteOpen } from "../commandPaletteContext";
import { useClientSettings } from "../hooks/useSettings";
import { openNewThreadPicker } from "../newThreadPickerBus";
import { openCommandPalette } from "../commandPaletteBus";
import { useProjects } from "../state/entities";
import { dispatchPreviewAction } from "../components/preview/previewActionBus";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
Expand Down Expand Up @@ -80,11 +80,11 @@ function ChatRouteGlobalShortcuts() {
if (command === "chat.new") {
event.preventDefault();
event.stopPropagation();
// Sidebar v2 routes creation through its project picker whenever
// Sidebar v2 routes creation through the command palette whenever
// there is a real choice to make; v1 (and single-project setups)
// keep the immediate contextual create.
if (sidebarV2Enabled && projectCount > 1) {
openNewThreadPicker();
openCommandPalette({ open: "new-thread-in" });
return;
}
void startNewThreadFromContext({
Expand Down
Loading