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 {...@@ -112,7 +112,7 @@ import {
112} from "../types";112} from "../types";
113import { useTheme } from "../hooks/useTheme";113import { useTheme } from "../hooks/useTheme";
114import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";114import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
115import { isCommandPaletteOpen } from "../commandPaletteContext";115import { isCommandPaletteOpen } from "../commandPaletteBus";
116import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";116import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
117import { useMediaQuery } from "../hooks/useMediaQuery";117import { useMediaQuery } from "../hooks/useMediaQuery";
118import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";118import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
apps/web/src/components/CommandPalette.tsx+62-15
...@@ -43,7 +43,7 @@ import {...@@ -43,7 +43,7 @@ import {
43 type ReactNode,43 type ReactNode,
44} from "react";44} from "react";
45import { useAtomValue } from "@effect/atom-react";45import { useAtomValue } from "@effect/atom-react";
46import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext";46
47import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";47import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
48import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";48import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
49import { useHandleNewThread } from "../hooks/useHandleNewThread";49import { useHandleNewThread } from "../hooks/useHandleNewThread";
...@@ -77,6 +77,7 @@ import {...@@ -77,6 +77,7 @@ import {
77 isUnsupportedWindowsProjectPath,77 isUnsupportedWindowsProjectPath,
78 resolveProjectPathForDispatch,78 resolveProjectPathForDispatch,
79} from "../lib/projectPaths";79} from "../lib/projectPaths";
80import { onOpenCommandPalette } from "../commandPaletteBus";
80import { isTerminalFocused } from "../lib/terminalFocus";81import { isTerminalFocused } from "../lib/terminalFocus";
81import { getLatestThreadForProject } from "../lib/threadSort";82import { getLatestThreadForProject } from "../lib/threadSort";
82import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";83import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils";
...@@ -334,7 +335,7 @@ function errorMessage(error: unknown): string {...@@ -334,7 +335,7 @@ function errorMessage(error: unknown): string {
334}335}
335336
336interface CommandPaletteOpenIntent {337interface CommandPaletteOpenIntent {
337 readonly kind: "add-project";338 readonly kind: "add-project" | "new-thread-in";
338}339}
339340
340interface CommandPaletteUiState {341interface CommandPaletteUiState {
...@@ -346,6 +347,7 @@ type CommandPaletteUiAction =...@@ -346,6 +347,7 @@ type CommandPaletteUiAction =
346 | { readonly _tag: "SetOpen"; readonly open: boolean }347 | { readonly _tag: "SetOpen"; readonly open: boolean }
347 | { readonly _tag: "Toggle" }348 | { readonly _tag: "Toggle" }
348 | { readonly _tag: "OpenAddProject" }349 | { readonly _tag: "OpenAddProject" }
350 | { readonly _tag: "OpenNewThreadIn" }
349 | { readonly _tag: "ClearOpenIntent" };351 | { readonly _tag: "ClearOpenIntent" };
350352
351function reduceCommandPaletteUiState(353function reduceCommandPaletteUiState(
...@@ -362,6 +364,8 @@ function reduceCommandPaletteUiState(...@@ -362,6 +364,8 @@ function reduceCommandPaletteUiState(
362 return { open: !state.open, openIntent: null };364 return { open: !state.open, openIntent: null };
363 case "OpenAddProject":365 case "OpenAddProject":
364 return { open: true, openIntent: { kind: "add-project" } };366 return { open: true, openIntent: { kind: "add-project" } };
367 case "OpenNewThreadIn":
368 return { open: true, openIntent: { kind: "new-thread-in" } };
365 case "ClearOpenIntent":369 case "ClearOpenIntent":
366 return state.openIntent ? { ...state, openIntent: null } : state;370 return state.openIntent ? { ...state, openIntent: null } : state;
367 }371 }
...@@ -375,6 +379,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -375,6 +379,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {
375 const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []);379 const setOpen = useCallback((open: boolean) => dispatch({ _tag: "SetOpen", open }), []);
376 const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []);380 const toggleOpen = useCallback(() => dispatch({ _tag: "Toggle" }), []);
377 const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []);381 const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []);
382 const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);
378 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);383 const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
379 const keybindings = useAtomValue(primaryServerKeybindingsAtom);384 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
380 const composerHandleRef = useRef<ChatComposerHandle | null>(null);385 const composerHandleRef = useRef<ChatComposerHandle | null>(null);
...@@ -409,20 +414,32 @@ export function CommandPalette({ children }: { children: ReactNode }) {...@@ -409,20 +414,32 @@ export function CommandPalette({ children }: { children: ReactNode }) {
409 return () => window.removeEventListener("keydown", onKeyDown);414 return () => window.removeEventListener("keydown", onKeyDown);
410 }, [keybindings, terminalOpen, toggleOpen]);415 }, [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
412 return (431 return (
413 <OpenAddProjectCommandPaletteProvider openAddProject={openAddProject}>432 <ComposerHandleContext value={composerHandleRef}>
414 <ComposerHandleContext value={composerHandleRef}>433 <CommandDialog open={state.open} onOpenChange={setOpen}>
415 <CommandDialog open={state.open} onOpenChange={setOpen}>434 {children}
416 {children}435 <CommandPaletteDialog
417 <CommandPaletteDialog436 open={state.open}
418 open={state.open}437 openIntent={state.openIntent}
419 openIntent={state.openIntent}438 setOpen={setOpen}
420 setOpen={setOpen}439 clearOpenIntent={clearOpenIntent}
421 clearOpenIntent={clearOpenIntent}440 />
422 />441 </CommandDialog>
423 </CommandDialog>442 </ComposerHandleContext>
424 </ComposerHandleContext>
425 </OpenAddProjectCommandPaletteProvider>
426 );443 );
427}444}
428445
...@@ -960,6 +977,36 @@ function OpenCommandPaletteDialog(props: {...@@ -960,6 +977,36 @@ function OpenCommandPaletteDialog(props: {
960 openAddProjectFlow();977 openAddProjectFlow();
961 }, [clearOpenIntent, openAddProjectFlow, openIntent]);978 }, [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
963 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];1010 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];
9641011
965 if (projects.length > 0) {1012 if (projects.length > 0) {
apps/web/src/components/Sidebar.tsx+5-2
...@@ -177,7 +177,7 @@ import {...@@ -177,7 +177,7 @@ import {
177 useSidebar,177 useSidebar,
178} from "./ui/sidebar";178} from "./ui/sidebar";
179import { useThreadSelectionStore } from "../threadSelectionStore";179import { useThreadSelectionStore } from "../threadSelectionStore";
180import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";180import { openCommandPalette } from "../commandPaletteBus";
181import {181import {
182 archiveSelectedThreadEntries,182 archiveSelectedThreadEntries,
183 buildMultiSelectThreadContextMenuItems,183 buildMultiSelectThreadContextMenuItems,
...@@ -3086,7 +3086,10 @@ export default function Sidebar() {...@@ -3086,7 +3086,10 @@ export default function Sidebar() {
3086 : false,3086 : false,
3087 );3087 );
3088 const keybindings = useAtomValue(primaryServerKeybindingsAtom);3088 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
3089 const openAddProjectCommandPalette = useOpenAddProjectCommandPalette();3089 const openAddProjectCommandPalette = useCallback(
3090 () => openCommandPalette({ open: "add-project" }),
3091 [],
3092 );
3090 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<3093 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
3091 ReadonlySet<string>3094 ReadonlySet<string>
3092 >(() => new Set());3095 >(() => new Set());
apps/web/src/components/SidebarV2.tsx+10-146
...@@ -57,14 +57,8 @@ import { useUiStateStore } from "../uiStateStore";...@@ -57,14 +57,8 @@ import { useUiStateStore } from "../uiStateStore";
57import { useThreadSelectionStore } from "../threadSelectionStore";57import { useThreadSelectionStore } from "../threadSelectionStore";
58import { useThreadActions } from "../hooks/useThreadActions";58import { useThreadActions } from "../hooks/useThreadActions";
59import { useHandleNewThread } from "../hooks/useHandleNewThread";59import { useHandleNewThread } from "../hooks/useHandleNewThread";
60import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";60import { openCommandPalette } from "../commandPaletteBus";
61import { onOpenNewThreadPicker } from "../newThreadPickerBus";61import { startNewThreadFromContext } from "../lib/chatThreadActions";
62import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog";
63import {
64 resolveThreadActionProjectRef,
65 startNewThreadFromContext,
66 startNewThreadInProjectFromContext,
67} from "../lib/chatThreadActions";
68import { useClientSettings } from "../hooks/useSettings";62import { useClientSettings } from "../hooks/useSettings";
69import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";63import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
70import { useProjects, useThreadShells } from "../state/entities";64import { useProjects, useThreadShells } from "../state/entities";
...@@ -630,7 +624,10 @@ export default function SidebarV2() {...@@ -630,7 +624,10 @@ export default function SidebarV2() {
630 reportFailure: false,624 reportFailure: false,
631 });625 });
632 const newThreadContext = useHandleNewThread();626 const newThreadContext = useHandleNewThread();
633 const openAddProjectCommandPalette = useOpenAddProjectCommandPalette();627 const openAddProjectCommandPalette = useCallback(
628 () => openCommandPalette({ open: "add-project" }),
629 [],
630 );
634 const { environments } = useEnvironments();631 const { environments } = useEnvironments();
635 const primaryEnvironmentId = usePrimaryEnvironmentId();632 const primaryEnvironmentId = usePrimaryEnvironmentId();
636 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);633 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
...@@ -1301,12 +1298,8 @@ export default function SidebarV2() {...@@ -1301,12 +1298,8 @@ export default function SidebarV2() {
13011298
1302 // New thread defaults to the project you're in (active thread's project,1299 // New thread defaults to the project you're in (active thread's project,
1303 // falling back to the top project) — same resolution the command palette1300 // falling back to the top project) — same resolution the command palette
1304 // uses. The chevron menu is the explicit project picker the flat list no1301 // uses. The command palette already offers a "New thread in..." submenu
1305 // longer gets from per-project headers.1302 // for multi-project setups.
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)), []);
1310 const handleNewThreadClick = useCallback(() => {1303 const handleNewThreadClick = useCallback(() => {
1311 // One project: nothing to pick, create immediately.1304 // One project: nothing to pick, create immediately.
1312 if (projects.length <= 1) {1305 if (projects.length <= 1) {
...@@ -1319,57 +1312,9 @@ export default function SidebarV2() {...@@ -1319,57 +1312,9 @@ export default function SidebarV2() {
1319 });1312 });
1320 return;1313 return;
1321 }1314 }
1322 setNewThreadPickerOpen(true);1315 if (isMobile) setOpenMobile(false);
1316 openCommandPalette({ open: "new-thread-in" });
1323 }, [isMobile, newThreadContext, projects.length, setOpenMobile]);1317 }, [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
1374 const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle");1319 const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle");
1375 // Same resolution as v1: prefer the local-thread binding, fall back to1320 // Same resolution as v1: prefer the local-thread binding, fall back to
...@@ -1639,87 +1584,6 @@ export default function SidebarV2() {...@@ -1639,87 +1584,6 @@ export default function SidebarV2() {
1639 </SidebarContent>1584 </SidebarContent>
1640 <SidebarSeparator />1585 <SidebarSeparator />
1641 <SidebarChromeFooter />1586 <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>
1723 </>1587 </>
1724 );1588 );
1725}1589}
apps/web/src/components/chat/DraftHeroHeadline.tsx+3-3
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1import type { ScopedProjectRef } from "@t3tools/contracts";1import type { ScopedProjectRef } from "@t3tools/contracts";
2import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";2import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
3import { FolderPlusIcon } from "lucide-react";3import { FolderPlusIcon } from "lucide-react";
4import { useMemo } from "react";4import { useCallback, useMemo } from "react";
55
6import { useOpenAddProjectCommandPalette } from "~/commandPaletteContext";6import { openCommandPalette } from "~/commandPaletteBus";
7import { useNewThreadHandler } from "~/hooks/useHandleNewThread";7import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
8import { useProjects, useThreadShells } from "~/state/entities";8import { useProjects, useThreadShells } from "~/state/entities";
9import { sortScopedProjectsForSidebar } from "../Sidebar.logic";9import { sortScopedProjectsForSidebar } from "../Sidebar.logic";
...@@ -29,7 +29,7 @@ export function DraftHeroHeadline({...@@ -29,7 +29,7 @@ export function DraftHeroHeadline({
29 const projects = useProjects();29 const projects = useProjects();
30 const threads = useThreadShells();30 const threads = useThreadShells();
31 const handleNewThread = useNewThreadHandler();31 const handleNewThread = useNewThreadHandler();
32 const openAddProject = useOpenAddProjectCommandPalette();32 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
3333
34 const orderedProjects = useMemo(34 const orderedProjects = useMemo(
35 () => sortScopedProjectsForSidebar(projects, threads, "updated_at"),35 () => 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 @@...@@ -1,9 +1,9 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { createFileRoute, Link } from "@tanstack/react-router";2import { createFileRoute, Link } from "@tanstack/react-router";
3import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react";3import { 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";
7import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";7import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";
8import { Button } from "../components/ui/button";8import { Button } from "../components/ui/button";
9import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";9import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
...@@ -105,7 +105,7 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {...@@ -105,7 +105,7 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {
105}105}
106106
107function NoProjectsHero() {107function NoProjectsHero() {
108 const openAddProject = useOpenAddProjectCommandPalette();108 const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []);
109109
110 return (110 return (
111 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">111 <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";...@@ -2,9 +2,9 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import { useEffect } from "react";3import { useEffect } from "react";
44
5import { isCommandPaletteOpen } from "../commandPaletteContext";5import { isCommandPaletteOpen } from "../commandPaletteBus";
6import { useClientSettings } from "../hooks/useSettings";6import { useClientSettings } from "../hooks/useSettings";
7import { openNewThreadPicker } from "../newThreadPickerBus";7import { openCommandPalette } from "../commandPaletteBus";
8import { useProjects } from "../state/entities";8import { useProjects } from "../state/entities";
9import { dispatchPreviewAction } from "../components/preview/previewActionBus";9import { dispatchPreviewAction } from "../components/preview/previewActionBus";
10import { useHandleNewThread } from "../hooks/useHandleNewThread";10import { useHandleNewThread } from "../hooks/useHandleNewThread";
...@@ -80,11 +80,11 @@ function ChatRouteGlobalShortcuts() {...@@ -80,11 +80,11 @@ function ChatRouteGlobalShortcuts() {
80 if (command === "chat.new") {80 if (command === "chat.new") {
81 event.preventDefault();81 event.preventDefault();
82 event.stopPropagation();82 event.stopPropagation();
83 // Sidebar v2 routes creation through its project picker whenever83 // Sidebar v2 routes creation through the command palette whenever
84 // there is a real choice to make; v1 (and single-project setups)84 // there is a real choice to make; v1 (and single-project setups)
85 // keep the immediate contextual create.85 // keep the immediate contextual create.
86 if (sidebarV2Enabled && projectCount > 1) {86 if (sidebarV2Enabled && projectCount > 1) {
87 openNewThreadPicker();87 openCommandPalette({ open: "new-thread-in" });
88 return;88 return;
89 }89 }
90 void startNewThreadFromContext({90 void startNewThreadFromContext({