authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-07-22 21:24:09+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 15:54:09+00:00
log9fe4832a3f87e48152f915cfac9f85ec78e14e24
tree5e891ef0204e26eb05886903a8474b6df628e3f1
parent376c149eac201fd1492c7b7512a1de96c3b345b7
signature Signed by PGP key B5690EEEBB952194

fix: open command palette instead of custom dialog for new thread picker in SidebarV2 (#4269)


10 files changed, 118 insertions(+), 217 deletions(-)

apps/web/src/commandPaletteBus.ts created+30
......@@ -0,0 +1,30 @@
1// Tiny event bus allowing components to programmatically open the command palette
2// without owning its React state.
3const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette";
4
5export interface CommandPaletteOpenDetail {
6 readonly open?: "add-project" | "new-thread-in";
7}
8
9export function openCommandPalette(detail?: CommandPaletteOpenDetail): void {
10 window.dispatchEvent(
11 new CustomEvent(COMMAND_PALETTE_OPEN_EVENT, detail ? { detail } : undefined),
12 );
13}
14
15export function onOpenCommandPalette(
16 listener: (detail: CommandPaletteOpenDetail) => void,
17): () => void {
18 const handler = (event: Event) => {
19 listener((event as CustomEvent<CommandPaletteOpenDetail>).detail ?? {});
20 };
21 window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, handler);
22 return () => window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, handler);
23}
24
25/** Read at event time so consumers do not subscribe to transient dialog state. */
26export function isCommandPaletteOpen(): boolean {
27 return (
28 typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null
29 );
30}
apps/web/src/commandPaletteContext.tsx deleted-29
......@@ -1,29 +0,0 @@
1import { createContext, use, type ReactNode } from "react";
2
3const OpenAddProjectCommandPaletteContext = createContext<(() => void) | null>(null);
4
5export function OpenAddProjectCommandPaletteProvider(props: {
6 readonly children: ReactNode;
7 readonly openAddProject: () => void;
8}) {
9 return (
10 <OpenAddProjectCommandPaletteContext value={props.openAddProject}>
11 {props.children}
12 </OpenAddProjectCommandPaletteContext>
13 );
14}
15
16export function useOpenAddProjectCommandPalette(): () => void {
17 const openAddProject = use(OpenAddProjectCommandPaletteContext);
18 if (!openAddProject) {
19 throw new Error("Command palette actions must be used inside CommandPalette");
20 }
21 return openAddProject;
22}
23
24/** Read at event time so the chat tree does not subscribe to transient dialog state. */
25export function isCommandPaletteOpen(): boolean {
26 return (
27 typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null
28 );
29}
apps/web/src/components/ChatView.tsx+1-1
......@@ -112,7 +112,7 @@ import {
112112} from "../types";
113113import { useTheme } from "../hooks/useTheme";
114114import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
115import { isCommandPaletteOpen } from "../commandPaletteContext";
115import { isCommandPaletteOpen } from "../commandPaletteBus";
116116import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
117117import { useMediaQuery } from "../hooks/useMediaQuery";
118118import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
apps/web/src/components/CommandPalette.tsx+62-15
......@@ -43,7 +43,7 @@ import {
4343 type ReactNode,
4444} from "react";
4545import { useAtomValue } from "@effect/atom-react";
46import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext";
46
4747import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
4848import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
4949import { useHandleNewThread } from "../hooks/useHandleNewThread";
......@@ -77,6 +77,7 @@ import {
7777 isUnsupportedWindowsProjectPath,
7878 resolveProjectPathForDispatch,
7979} from "../lib/projectPaths";
80import { onOpenCommandPalette } from "../commandPaletteBus";
8081import { isTerminalFocused } from "../lib/terminalFocus";
8182import { getLatestThreadForProject } from "../lib/threadSort";
8283import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";
......@@ -334,7 +335,7 @@ function errorMessage(error: unknown): string {
334335}
335336
336337interface CommandPaletteOpenIntent {
337 readonly kind: "add-project";
338 readonly kind: "add-project" | "new-thread-in";
338339}
339340
340341interface CommandPaletteUiState {
......@@ -346,6 +347,7 @@ type CommandPaletteUiAction =
346347 | { readonly _tag: "SetOpen"; readonly open: boolean }
347348 | { readonly _tag: "Toggle" }
348349 | { readonly _tag: "OpenAddProject" }
350 | { readonly _tag: "OpenNewThreadIn" }
349351 | { readonly _tag: "ClearOpenIntent" };
350352
351353function reduceCommandPaletteUiState(
......@@ -362,6 +364,8 @@ function reduceCommandPaletteUiState(
362364 return { open: !state.open, openIntent: null };
363365 case "OpenAddProject":
364366 return { open: true, openIntent: { kind: "add-project" } };
367 case "OpenNewThreadIn":
368 return { open: true, openIntent: { kind: "new-thread-in" } };
365369 case "ClearOpenIntent":
366370 return state.openIntent ? { ...state, openIntent: null } : state;
367371 }
......@@ -375,6 +379,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {
375379 const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []);
376380 const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []);
377381 const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []);
382 const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);
378383 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
379384 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
380385 const composerHandleRef = useRef<ChatComposerHandle | null>(null);
......@@ -409,20 +414,32 @@ export function CommandPalette({ children }: { children: ReactNode }) {
409414 return () => window.removeEventListener("keydown", onKeyDown);
410415 }, [keybindings, terminalOpen, toggleOpen]);
411416
417 useEffect(
418 () =>
419 onOpenCommandPalette((detail) => {
420 if (detail.open === "new-thread-in") {
421 openNewThreadIn();
422 } else if (detail.open === "add-project") {
423 openAddProject();
424 } else {
425 setOpen(true);
426 }
427 }),
428 [openAddProject, openNewThreadIn, setOpen],
429 );
430
412431 return (
413 <OpenAddProjectCommandPaletteProvider openAddProject={openAddProject}>
414 <ComposerHandleContext value={composerHandleRef}>
415 <CommandDialog open={state.open} onOpenChange={setOpen}>
416 {children}
417 <CommandPaletteDialog
418 open={state.open}
419 openIntent={state.openIntent}
420 setOpen={setOpen}
421 clearOpenIntent={clearOpenIntent}
422 />
423 </CommandDialog>
424 </ComposerHandleContext>
425 </OpenAddProjectCommandPaletteProvider>
432 <ComposerHandleContext value={composerHandleRef}>
433 <CommandDialog open={state.open} onOpenChange={setOpen}>
434 {children}
435 <CommandPaletteDialog
436 open={state.open}
437 openIntent={state.openIntent}
438 setOpen={setOpen}
439 clearOpenIntent={clearOpenIntent}
440 />
441 </CommandDialog>
442 </ComposerHandleContext>
426443 );
427444}
428445
......@@ -960,6 +977,36 @@ function OpenCommandPaletteDialog(props: {
960977 openAddProjectFlow();
961978 }, [clearOpenIntent, openAddProjectFlow, openIntent]);
962979
980 useLayoutEffect(() => {
981 if (openIntent?.kind !== "new-thread-in" || projectThreadItems.length === 0) {
982 return;
983 }
984 clearOpenIntent();
985 setAddProjectCloneFlow(null);
986 setViewStack([]);
987 setQuery("");
988 const currentPrefix =
989 currentProjectEnvironmentId && currentProjectId
990 ? `new-thread-in:${currentProjectEnvironmentId}:${currentProjectId}`
991 : null;
992 const prioritized = currentPrefix
993 ? [
994 ...projectThreadItems.filter((item) => item.value === currentPrefix),
995 ...projectThreadItems.filter((item) => item.value !== currentPrefix),
996 ]
997 : projectThreadItems;
998 pushPaletteView({
999 addonIcon: <SquarePenIcon className={ADDON_ICON_CLASS} />,
1000 groups: [{ value: "projects", label: "Projects", items: prioritized }],
1001 });
1002 }, [
1003 clearOpenIntent,
1004 currentProjectEnvironmentId,
1005 currentProjectId,
1006 openIntent,
1007 projectThreadItems,
1008 ]);
1009
9631010 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];
9641011
9651012 if (projects.length > 0) {
apps/web/src/components/Sidebar.tsx+5-2
......@@ -177,7 +177,7 @@ import {
177177 useSidebar,
178178} from "./ui/sidebar";
179179import { useThreadSelectionStore } from "../threadSelectionStore";
180import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
180import { openCommandPalette } from "../commandPaletteBus";
181181import {
182182 archiveSelectedThreadEntries,
183183 buildMultiSelectThreadContextMenuItems,
......@@ -3086,7 +3086,10 @@ export default function Sidebar() {
30863086 : false,
30873087 );
30883088 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
3089 const openAddProjectCommandPalette = useOpenAddProjectCommandPalette();
3089 const openAddProjectCommandPalette = useCallback(
3090 () => openCommandPalette({ open: "add-project" }),
3091 [],
3092 );
30903093 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
30913094 ReadonlySet<string>
30923095 >(() => new Set());
apps/web/src/components/SidebarV2.tsx+10-146
......@@ -57,14 +57,8 @@ import { useUiStateStore } from "../uiStateStore";
5757import { useThreadSelectionStore } from "../threadSelectionStore";
5858import { useThreadActions } from "../hooks/useThreadActions";
5959import { useHandleNewThread } from "../hooks/useHandleNewThread";
60import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
61import { onOpenNewThreadPicker } from "../newThreadPickerBus";
62import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog";
63import {
64 resolveThreadActionProjectRef,
65 startNewThreadFromContext,
66 startNewThreadInProjectFromContext,
67} from "../lib/chatThreadActions";
60import { openCommandPalette } from "../commandPaletteBus";
61import { startNewThreadFromContext } from "../lib/chatThreadActions";
6862import { useClientSettings } from "../hooks/useSettings";
6963import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
7064import { useProjects, useThreadShells } from "../state/entities";
......@@ -630,7 +624,10 @@ export default function SidebarV2() {
630624 reportFailure: false,
631625 });
632626 const newThreadContext = useHandleNewThread();
633 const openAddProjectCommandPalette = useOpenAddProjectCommandPalette();
627 const openAddProjectCommandPalette = useCallback(
628 () => openCommandPalette({ open: "add-project" }),
629 [],
630 );
634631 const { environments } = useEnvironments();
635632 const primaryEnvironmentId = usePrimaryEnvironmentId();
636633 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
......@@ -1301,12 +1298,8 @@ export default function SidebarV2() {
13011298
13021299 // New thread defaults to the project you're in (active thread's project,
13031300 // falling back to the top project) — same resolution the command palette
1304 // uses. The chevron menu is the explicit project picker the flat list no
1305 // longer gets from per-project headers.
1306 const [newThreadPickerOpen, setNewThreadPickerOpen] = useState(false);
1307 // chat.new (mod+shift+o / mod+n) is handled by the _chat route layout; in
1308 // v2 with multiple projects it opens this picker via the event bus.
1309 useEffect(() => onOpenNewThreadPicker(() => setNewThreadPickerOpen(true)), []);
1301 // uses. The command palette already offers a "New thread in..." submenu
1302 // for multi-project setups.
13101303 const handleNewThreadClick = useCallback(() => {
13111304 // One project: nothing to pick, create immediately.
13121305 if (projects.length <= 1) {
......@@ -1319,57 +1312,9 @@ export default function SidebarV2() {
13191312 });
13201313 return;
13211314 }
1322 setNewThreadPickerOpen(true);
1315 if (isMobile) setOpenMobile(false);
1316 openCommandPalette({ open: "new-thread-in" });
13231317 }, [isMobile, newThreadContext, projects.length, setOpenMobile]);
1324 const createThreadInProject = useCallback(
1325 (environmentId: (typeof projects)[number]["environmentId"], projectId: string) => {
1326 setNewThreadPickerOpen(false);
1327 if (isMobile) setOpenMobile(false);
1328 const project = projects.find(
1329 (candidate) => candidate.environmentId === environmentId && candidate.id === projectId,
1330 );
1331 if (!project) return;
1332 void startNewThreadInProjectFromContext(
1333 {
1334 activeDraftThread: newThreadContext.activeDraftThread,
1335 activeThread: newThreadContext.activeThread ?? undefined,
1336 defaultProjectRef: newThreadContext.defaultProjectRef,
1337 handleNewThread: newThreadContext.handleNewThread,
1338 },
1339 scopeProjectRef(project.environmentId, project.id),
1340 );
1341 },
1342 [isMobile, newThreadContext, projects, setOpenMobile],
1343 );
1344 const contextualProjectRef = resolveThreadActionProjectRef({
1345 activeDraftThread: newThreadContext.activeDraftThread,
1346 activeThread: newThreadContext.activeThread ?? undefined,
1347 defaultProjectRef: newThreadContext.defaultProjectRef,
1348 handleNewThread: newThreadContext.handleNewThread,
1349 });
1350 const newThreadTargetRef = scopedProject
1351 ? scopeProjectRef(scopedProject.environmentId, scopedProject.id)
1352 : contextualProjectRef;
1353 const newThreadTargetProject = newThreadTargetRef
1354 ? (projects.find(
1355 (project) =>
1356 project.environmentId === newThreadTargetRef.environmentId &&
1357 project.id === newThreadTargetRef.projectId,
1358 ) ?? null)
1359 : null;
1360 // Picker order: the contextual default first (preselected), everything else
1361 // after — the common case is Enter/click on the top row.
1362 const newThreadPickerProjects = useMemo(() => {
1363 if (!newThreadTargetProject) return projects;
1364 return [
1365 newThreadTargetProject,
1366 ...projects.filter(
1367 (project) =>
1368 project.environmentId !== newThreadTargetProject.environmentId ||
1369 project.id !== newThreadTargetProject.id,
1370 ),
1371 ];
1372 }, [newThreadTargetProject, projects]);
13731318
13741319 const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle");
13751320 // Same resolution as v1: prefer the local-thread binding, fall back to
......@@ -1639,87 +1584,6 @@ export default function SidebarV2() {
16391584 </SidebarContent>
16401585 <SidebarSeparator />
16411586 <SidebarChromeFooter />
1642 <Dialog open={newThreadPickerOpen} onOpenChange={setNewThreadPickerOpen}>
1643 <DialogPopup className="max-w-sm p-0">
1644 <DialogHeader className="px-4 pb-2 pt-4">
1645 <DialogTitle className="text-sm">New thread in…</DialogTitle>
1646 </DialogHeader>
1647 <div
1648 className="flex flex-col gap-0.5 px-2 pb-3"
1649 role="listbox"
1650 aria-label="Choose a project for the new thread"
1651 onKeyDown={(event) => {
1652 if (
1653 event.key !== "ArrowDown" &&
1654 event.key !== "ArrowUp" &&
1655 event.key !== "Home" &&
1656 event.key !== "End"
1657 ) {
1658 return;
1659 }
1660 const container = event.currentTarget;
1661 const options = [...container.querySelectorAll<HTMLButtonElement>("button")];
1662 if (options.length === 0) return;
1663 const currentIndex = options.findIndex((option) => option === document.activeElement);
1664 const nextIndex =
1665 event.key === "Home"
1666 ? 0
1667 : event.key === "End"
1668 ? options.length - 1
1669 : event.key === "ArrowDown"
1670 ? (currentIndex + 1) % options.length
1671 : (currentIndex - 1 + options.length) % options.length;
1672 event.preventDefault();
1673 options[nextIndex]?.focus();
1674 }}
1675 >
1676 {newThreadPickerProjects.map((project, index) => {
1677 const isDefault = index === 0 && newThreadTargetProject !== null;
1678 return (
1679 <button
1680 key={`${project.environmentId}:${project.id}`}
1681 type="button"
1682 autoFocus={isDefault}
1683 onClick={() => createThreadInProject(project.environmentId, project.id)}
1684 className={cn(
1685 "flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
1686 "hover:bg-accent focus:bg-accent focus:outline-none",
1687 isDefault && "bg-accent/50",
1688 )}
1689 >
1690 <ProjectFavicon
1691 environmentId={project.environmentId}
1692 cwd={project.workspaceRoot}
1693 className="size-4"
1694 />
1695 <span className="min-w-0 flex-1">
1696 <span className="block truncate text-[13px] font-medium text-foreground">
1697 {project.title}
1698 </span>
1699 <span className="block truncate font-mono text-[10px] text-muted-foreground/60">
1700 {project.workspaceRoot}
1701 </span>
1702 </span>
1703 {isDefault ? (
1704 <Kbd className="h-4 shrink-0 rounded-sm px-1.5 text-[10px]">↵</Kbd>
1705 ) : null}
1706 </button>
1707 );
1708 })}
1709 <button
1710 type="button"
1711 onClick={() => {
1712 setNewThreadPickerOpen(false);
1713 openAddProjectCommandPalette();
1714 }}
1715 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"
1716 >
1717 <PlusIcon className="size-4" />
1718 Add project
1719 </button>
1720 </div>
1721 </DialogPopup>
1722 </Dialog>
17231587 </>
17241588 );
17251589}
apps/web/src/components/chat/DraftHeroHeadline.tsx+3-3
......@@ -1,9 +1,9 @@
11import type { ScopedProjectRef } from "@t3tools/contracts";
22import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
33import { FolderPlusIcon } from "lucide-react";
4import { useMemo } from "react";
4import { useCallback, useMemo } from "react";
55
6import { useOpenAddProjectCommandPalette } from "~/commandPaletteContext";
6import { openCommandPalette } from "~/commandPaletteBus";
77import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
88import { useProjects, useThreadShells } from "~/state/entities";
99import { sortScopedProjectsForSidebar } from "../Sidebar.logic";
......@@ -29,7 +29,7 @@ export function DraftHeroHeadline({
2929 const projects = useProjects();
3030 const threads = useThreadShells();
3131 const handleNewThread = useNewThreadHandler();
32 const openAddProject = useOpenAddProjectCommandPalette();
32 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
3333
3434 const orderedProjects = useMemo(
3535 () => sortScopedProjectsForSidebar(projects, threads, "updated_at"),
apps/web/src/newThreadPickerBus.ts deleted-14
......@@ -1,14 +0,0 @@
1// Tiny event bus connecting the global chat.new shortcut (handled in the
2// _chat route layout) to SidebarV2's project picker dialog. The route layer
3// can't render the picker itself — it lives with the sidebar — and the
4// sidebar can't own the window keydown handler without racing the layout's.
5const NEW_THREAD_PICKER_EVENT = "t3code:open-new-thread-picker";
6
7export function openNewThreadPicker(): void {
8 window.dispatchEvent(new CustomEvent(NEW_THREAD_PICKER_EVENT));
9}
10
11export function onOpenNewThreadPicker(listener: () => void): () => void {
12 window.addEventListener(NEW_THREAD_PICKER_EVENT, listener);
13 return () => window.removeEventListener(NEW_THREAD_PICKER_EVENT, listener);
14}
apps/web/src/routes/_chat.index.tsx+3-3
......@@ -1,9 +1,9 @@
11import { scopeProjectRef } from "@t3tools/client-runtime/environment";
22import { createFileRoute, Link } from "@tanstack/react-router";
33import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react";
4import { useEffect, useMemo, useRef, useState } from "react";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
55
6import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
6import { openCommandPalette } from "../commandPaletteBus";
77import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";
88import { Button } from "../components/ui/button";
99import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
......@@ -105,7 +105,7 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {
105105}
106106
107107function NoProjectsHero() {
108 const openAddProject = useOpenAddProjectCommandPalette();
108 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
109109
110110 return (
111111 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
apps/web/src/routes/_chat.tsx+4-4
......@@ -2,9 +2,9 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
22import { useAtomValue } from "@effect/atom-react";
33import { useEffect } from "react";
44
5import { isCommandPaletteOpen } from "../commandPaletteContext";
5import { isCommandPaletteOpen } from "../commandPaletteBus";
66import { useClientSettings } from "../hooks/useSettings";
7import { openNewThreadPicker } from "../newThreadPickerBus";
7import { openCommandPalette } from "../commandPaletteBus";
88import { useProjects } from "../state/entities";
99import { dispatchPreviewAction } from "../components/preview/previewActionBus";
1010import { useHandleNewThread } from "../hooks/useHandleNewThread";
......@@ -80,11 +80,11 @@ function ChatRouteGlobalShortcuts() {
8080 if (command === "chat.new") {
8181 event.preventDefault();
8282 event.stopPropagation();
83 // Sidebar v2 routes creation through its project picker whenever
83 // Sidebar v2 routes creation through the command palette whenever
8484 // there is a real choice to make; v1 (and single-project setups)
8585 // keep the immediate contextual create.
8686 if (sidebarV2Enabled && projectCount > 1) {
87 openNewThreadPicker();
87 openCommandPalette({ open: "new-thread-in" });
8888 return;
8989 }
9090 void startNewThreadFromContext({