From 11207d2519b554ef1ad27d1df5825795feb42e84 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 09:26:09 -0700 Subject: [PATCH] feat(web): finish unified grouped thread picker --- apps/web/src/commandPaletteBus.ts | 13 + .../components/CommandPalette.logic.test.ts | 106 +++++ .../src/components/CommandPalette.logic.ts | 46 ++- apps/web/src/components/CommandPalette.tsx | 362 +++++++++++------- .../src/components/CommandPaletteResults.tsx | 4 + apps/web/src/components/SidebarV2.tsx | 9 +- apps/web/src/lib/chatRouteShortcuts.test.ts | 76 ++++ apps/web/src/lib/chatRouteShortcuts.ts | 19 + apps/web/src/routes/_chat.tsx | 35 +- 9 files changed, 532 insertions(+), 138 deletions(-) create mode 100644 apps/web/src/lib/chatRouteShortcuts.test.ts create mode 100644 apps/web/src/lib/chatRouteShortcuts.ts diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992..22823339898 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -1,9 +1,13 @@ +import type { ScopedProjectRef } from "@t3tools/contracts"; + // Tiny event bus allowing components to programmatically open the command palette // without owning its React state. const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; +const COMMAND_PALETTE_CLOSE_EVENT = "t3code:close-command-palette"; export interface CommandPaletteOpenDetail { readonly open?: "add-project" | "new-thread-in"; + readonly preferredProjectRef?: ScopedProjectRef | null; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { @@ -22,6 +26,15 @@ export function onOpenCommandPalette( return () => window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, handler); } +export function closeCommandPalette(): void { + window.dispatchEvent(new Event(COMMAND_PALETTE_CLOSE_EVENT)); +} + +export function onCloseCommandPalette(listener: () => void): () => void { + window.addEventListener(COMMAND_PALETTE_CLOSE_EVENT, listener); + return () => window.removeEventListener(COMMAND_PALETTE_CLOSE_EVENT, listener); +} + /** Read at event time so consumers do not subscribe to transient dialog state. */ export function isCommandPaletteOpen(): boolean { return ( diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 902b7e87773..8163174a975 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + buildNewThreadPickerGroups, buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + prioritizeCommandPaletteItem, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -35,6 +37,110 @@ describe("enumerateCommandPaletteItems", () => { }); }); +const makeActionItem = (value: string) => ({ + kind: "action" as const, + value, + searchTerms: [], + title: value, + icon: null, + run: async () => undefined, +}); + +describe("prioritizeCommandPaletteItem", () => { + it("moves the preferred item to the front without disturbing the remaining order", () => { + const items = [makeActionItem("first"), makeActionItem("second"), makeActionItem("third")]; + + expect(prioritizeCommandPaletteItem(items, "third").map((item) => item.value)).toEqual([ + "third", + "first", + "second", + ]); + expect(prioritizeCommandPaletteItem(items, "missing")).toEqual(items); + }); +}); + +describe("buildNewThreadPickerGroups", () => { + const addProjectItem = makeActionItem("action:add-project"); + + it("waits for projects before showing an empty picker", () => { + expect( + buildNewThreadPickerGroups({ + projectItems: [], + addProjectItem, + areProjectsLoading: true, + }), + ).toEqual([]); + }); + + it("keeps Add project keyboard-addressable after project choices", () => { + const projectItem = makeActionItem("new-thread-in:environment-local:project-1"); + + expect( + buildNewThreadPickerGroups({ + projectItems: [projectItem], + addProjectItem, + areProjectsLoading: false, + }).map((group) => ({ + value: group.value, + items: group.items.map((item) => item.value), + })), + ).toEqual([ + { + value: "new-thread-projects", + items: ["new-thread-in:environment-local:project-1"], + }, + { value: "new-thread-actions", items: ["action:add-project"] }, + ]); + }); + + it("offers Add project when loading completes without projects", () => { + expect( + buildNewThreadPickerGroups({ + projectItems: [], + addProjectItem, + areProjectsLoading: false, + }), + ).toEqual([ + { + value: "new-thread-actions", + label: "Actions", + items: [addProjectItem], + }, + ]); + }); +}); + +describe("filterCommandPaletteGroups", () => { + it("keeps dedicated picker actions visible for the actions-only filter", () => { + const addProjectItem = { + ...makeActionItem("action:add-project"), + searchTerms: ["add project"], + }; + + expect( + filterCommandPaletteGroups({ + activeGroups: [ + { + value: "new-thread-actions", + label: "Actions", + items: [addProjectItem], + }, + ], + query: ">", + isInSubmenu: true, + projectSearchItems: [], + threadSearchItems: [], + }), + ).toEqual([ + { + value: "new-thread-actions", + label: "Actions", + items: [addProjectItem], + }, + ]); + }); +}); + const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f69c38e1a0f..98ce8d8bbf7 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -14,6 +14,7 @@ import { type Project, type SidebarThreadSummary, type Thread } from "../types"; export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; export const ADDON_ICON_CLASS = "size-4"; +export const NEW_THREAD_PROJECT_VIEW_GROUP = "new-thread-projects"; export interface CommandPaletteItem { readonly kind: "action" | "submenu"; @@ -68,6 +69,43 @@ export function enumerateCommandPaletteItems( }); } +export function prioritizeCommandPaletteItem( + items: ReadonlyArray, + preferredValue: string | null, +): CommandPaletteActionItem[] { + if (preferredValue === null) return [...items]; + + const preferredIndex = items.findIndex((item) => item.value === preferredValue); + if (preferredIndex <= 0) return [...items]; + + const preferredItem = items[preferredIndex]; + if (preferredItem === undefined) return [...items]; + return [preferredItem, ...items.slice(0, preferredIndex), ...items.slice(preferredIndex + 1)]; +} + +export function buildNewThreadPickerGroups(input: { + projectItems: ReadonlyArray; + addProjectItem: CommandPaletteActionItem; + areProjectsLoading: boolean; +}): CommandPaletteGroup[] { + if (input.areProjectsLoading && input.projectItems.length === 0) return []; + + const groups: CommandPaletteGroup[] = []; + if (input.projectItems.length > 0) { + groups.push({ + value: NEW_THREAD_PROJECT_VIEW_GROUP, + label: "Projects", + items: input.projectItems, + }); + } + groups.push({ + value: "new-thread-actions", + label: "Actions", + items: [input.addProjectItem], + }); + return groups; +} + export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; export function filterBrowseEntries(input: { @@ -229,6 +267,10 @@ function rankCommandPaletteItemMatch( return 0; } +function isCommandPaletteActionGroup(group: CommandPaletteGroup): boolean { + return group.value === "actions" || group.value === "new-thread-actions"; +} + export function filterCommandPaletteGroups(input: { activeGroups: ReadonlyArray; query: string; @@ -242,14 +284,14 @@ export function filterCommandPaletteGroups(input: { if (normalizedQuery.length === 0) { if (isActionsFilter) { - return input.activeGroups.filter((group) => group.value === "actions"); + return input.activeGroups.filter(isCommandPaletteActionGroup); } return [...input.activeGroups]; } let baseGroups = [...input.activeGroups]; if (isActionsFilter) { - baseGroups = baseGroups.filter((group) => group.value === "actions"); + baseGroups = baseGroups.filter(isCommandPaletteActionGroup); } else if (!input.isInSubmenu) { baseGroups = baseGroups.filter((group) => group.value !== "recent-threads"); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ffe3264fbf8..7efa275c54e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -11,6 +11,7 @@ import { type EnvironmentId, type FilesystemBrowseResult, type ProjectId, + type ScopedProjectRef, type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, @@ -57,7 +58,11 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useThreadShells, +} from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadInProjectFromContext, @@ -78,7 +83,7 @@ import { isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "../lib/projectPaths"; -import { onOpenCommandPalette } from "../commandPaletteBus"; +import { onCloseCommandPalette, onOpenCommandPalette } from "../commandPaletteBus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; @@ -93,6 +98,7 @@ import { import { ADDON_ICON_CLASS, buildBrowseGroups, + buildNewThreadPickerGroups, buildProjectActionItems, buildRootGroups, buildThreadActionItems, @@ -105,6 +111,8 @@ import { getCommandPaletteInputPlaceholder, getCommandPaletteMode, ITEM_ICON_CLASS, + NEW_THREAD_PROJECT_VIEW_GROUP, + prioritizeCommandPaletteItem, RECENT_THREAD_LIMIT, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; @@ -343,9 +351,12 @@ function errorMessage(error: unknown): string { return "An error occurred."; } -interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; -} +type CommandPaletteOpenIntent = + | { readonly kind: "add-project" } + | { + readonly kind: "new-thread-in"; + readonly preferredProjectRef: ScopedProjectRef | null; + }; interface CommandPaletteUiState { readonly open: boolean; @@ -356,7 +367,10 @@ type CommandPaletteUiAction = | { readonly _tag: "SetOpen"; readonly open: boolean } | { readonly _tag: "Toggle" } | { readonly _tag: "OpenAddProject" } - | { readonly _tag: "OpenNewThreadIn" } + | { + readonly _tag: "OpenNewThreadIn"; + readonly preferredProjectRef: ScopedProjectRef | null; + } | { readonly _tag: "ClearOpenIntent" }; function reduceCommandPaletteUiState( @@ -374,7 +388,13 @@ function reduceCommandPaletteUiState( case "OpenAddProject": return { open: true, openIntent: { kind: "add-project" } }; case "OpenNewThreadIn": - return { open: true, openIntent: { kind: "new-thread-in" } }; + return { + open: true, + openIntent: { + kind: "new-thread-in", + preferredProjectRef: action.preferredProjectRef, + }, + }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; } @@ -388,7 +408,14 @@ 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 openNewThreadIn = useCallback( + (preferredProjectRef?: ScopedProjectRef | null) => + dispatch({ + _tag: "OpenNewThreadIn", + preferredProjectRef: preferredProjectRef ?? null, + }), + [], + ); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const composerHandleRef = useRef(null); @@ -427,7 +454,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { () => onOpenCommandPalette((detail) => { if (detail.open === "new-thread-in") { - openNewThreadIn(); + openNewThreadIn(detail.preferredProjectRef); } else if (detail.open === "add-project") { openAddProject(); } else { @@ -437,6 +464,8 @@ export function CommandPalette({ children }: { children: ReactNode }) { [openAddProject, openNewThreadIn, setOpen], ); + useEffect(() => onCloseCommandPalette(() => setOpen(false)), [setOpen]); + return ( @@ -499,12 +528,18 @@ function OpenCommandPaletteDialog(props: { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); + const allEnvironmentShellsBootstrapped = useAllEnvironmentShellsBootstrapped(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; + const isNewThreadProjectPickerView = + currentView?.groups[0]?.value === NEW_THREAD_PROJECT_VIEW_GROUP; + const [newThreadPreferredProjectRef, setNewThreadPreferredProjectRef] = + useState(null); + const [newThreadPickerGeneration, setNewThreadPickerGeneration] = useState(0); const [browseGeneration, setBrowseGeneration] = useState(0); const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( null, @@ -574,6 +609,7 @@ function OpenCommandPaletteDialog(props: { }), [activeDraftThread, activeThread, defaultProjectRef, handleNewThread], ); + const effectiveNewThreadProjectRef = newThreadPreferredProjectRef ?? contextualProjectRef; const projectPickerEntries = useMemo( () => buildSidebarProjectPickerEntries({ @@ -582,6 +618,14 @@ function OpenCommandPaletteDialog(props: { }), [contextualProjectRef, projectGroups], ); + const newThreadProjectPickerEntries = useMemo( + () => + buildSidebarProjectPickerEntries({ + groups: projectGroups, + preferredProjectRef: effectiveNewThreadProjectRef, + }), + [effectiveNewThreadProjectRef, projectGroups], + ); const pickerProjects = useMemo( () => projectPickerEntries.map(({ group, targetProject }) => ({ @@ -590,6 +634,14 @@ function OpenCommandPaletteDialog(props: { })), [projectPickerEntries], ); + const newThreadPickerProjects = useMemo( + () => + newThreadProjectPickerEntries.map(({ group, targetProject }) => ({ + ...targetProject, + title: group.displayName, + })), + [newThreadProjectPickerEntries], + ); const projectGroupByTargetKey = useMemo( () => new Map( @@ -600,6 +652,16 @@ function OpenCommandPaletteDialog(props: { ), [projectPickerEntries], ); + const newThreadProjectGroupByTargetKey = useMemo( + () => + new Map( + newThreadProjectPickerEntries.map(({ group, targetProject }) => [ + `${targetProject.environmentId}:${targetProject.id}`, + group, + ]), + ), + [newThreadProjectPickerEntries], + ); const addProjectEnvironmentOptions = useMemo(() => { const options = environments.map((environment): AddProjectEnvironmentOption => { @@ -809,56 +871,80 @@ function OpenCommandPaletteDialog(props: { [openProjectFromSearch, pickerProjects, projectGroupByTargetKey], ); + const preferredNewThreadItemValue = useMemo(() => { + if (newThreadPreferredProjectRef === null) return null; + + const preferredEntry = newThreadProjectPickerEntries.find(({ group }) => + group.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === newThreadPreferredProjectRef.environmentId && + projectRef.projectId === newThreadPreferredProjectRef.projectId, + ), + ); + return preferredEntry + ? `new-thread-in:${preferredEntry.targetProject.environmentId}:${preferredEntry.targetProject.id}` + : null; + }, [newThreadPreferredProjectRef, newThreadProjectPickerEntries]); + const projectThreadItems = useMemo( () => enumerateCommandPaletteItems( - buildProjectActionItems({ - 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) => ( - - ), - 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, + prioritizeCommandPaletteItem( + buildProjectActionItems({ + projects: newThreadPickerProjects, + valuePrefix: "new-thread-in", + searchTerms: (project) => { + const group = newThreadProjectGroupByTargetKey.get( + `${project.environmentId}:${project.id}`, ); - await startNewThreadInProjectFromContext( - { - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }, - contextualRefBelongsToGroup - ? contextualProjectRef - : scopeProjectRef(project.environmentId, project.id), - ); - }, - }), + return ( + group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? + [] + ); + }, + icon: (project) => ( + + ), + runProject: async (project) => { + const group = newThreadProjectGroupByTargetKey.get( + `${project.environmentId}:${project.id}`, + ); + const preferredRefBelongsToGroup = + effectiveNewThreadProjectRef !== null && + group?.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === effectiveNewThreadProjectRef.environmentId && + projectRef.projectId === effectiveNewThreadProjectRef.projectId, + ); + await startNewThreadInProjectFromContext( + { + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }, + preferredRefBelongsToGroup + ? effectiveNewThreadProjectRef + : scopeProjectRef(project.environmentId, project.id), + ); + }, + }), + preferredNewThreadItemValue, + ), ), [ activeDraftThread, activeThread, - contextualProjectRef, defaultProjectRef, + effectiveNewThreadProjectRef, handleNewThread, - pickerProjects, - projectGroupByTargetKey, + newThreadPickerProjects, + newThreadProjectGroupByTargetKey, + preferredNewThreadItemValue, ], ); @@ -897,6 +983,9 @@ function OpenCommandPaletteDialog(props: { } function pushView(item: CommandPaletteSubmenuItem): void { + if (item.groups[0]?.value === NEW_THREAD_PROJECT_VIEW_GROUP) { + setNewThreadPreferredProjectRef(null); + } pushPaletteView({ addonIcon: item.addonIcon, groups: item.groups, @@ -906,6 +995,9 @@ function OpenCommandPaletteDialog(props: { function popView(): void { setAddProjectCloneFlow(null); + if (isNewThreadProjectPickerView) { + setNewThreadPreferredProjectRef(null); + } if (viewStack.length <= 1) { setAddProjectEnvironmentId(null); } @@ -1119,6 +1211,53 @@ function OpenCommandPaletteDialog(props: { startAddProjectSourceSelection, ]); + const addProjectActionItem: CommandPaletteActionItem = { + kind: "action", + value: "action:add-project", + searchTerms: [ + "add project", + "folder", + "directory", + "browse", + "clone", + "remote", + "repository", + "repo", + "git", + "github", + "gitlab", + "bitbucket", + "azure", + "devops", + "url", + "environment", + ], + title: "Add project", + icon: , + keepOpen: true, + run: async () => { + openAddProjectFlow(); + }, + }; + const newThreadPickerGroups = buildNewThreadPickerGroups({ + projectItems: projectThreadItems, + addProjectItem: addProjectActionItem, + areProjectsLoading: !allEnvironmentShellsBootstrapped, + }); + const newThreadPickerView = useMemo( + () => ({ + addonIcon: , + groups: [ + { + value: NEW_THREAD_PROJECT_VIEW_GROUP, + label: "Projects", + items: [], + }, + ], + }), + [], + ); + useLayoutEffect(() => { if (openIntent?.kind !== "add-project") { return; @@ -1128,40 +1267,19 @@ function OpenCommandPaletteDialog(props: { }, [clearOpenIntent, openAddProjectFlow, openIntent]); useLayoutEffect(() => { - if (openIntent?.kind !== "new-thread-in" || projectThreadItems.length === 0) { + if (openIntent?.kind !== "new-thread-in") { return; } + setNewThreadPreferredProjectRef(openIntent.preferredProjectRef); clearOpenIntent(); setAddProjectCloneFlow(null); - setViewStack([]); + setIsRemoteProjectLookingUp(false); + setIsRemoteProjectCloning(false); + setViewStack([newThreadPickerView]); + setHighlightedItemValue(null); setQuery(""); - const currentPrefix = - currentProjectEnvironmentId && currentProjectId - ? `new-thread-in:${currentProjectEnvironmentId}:${currentProjectId}` - : null; - const prioritized = currentPrefix - ? [ - ...projectThreadItems.filter((item) => item.value === currentPrefix), - ...projectThreadItems.filter((item) => item.value !== currentPrefix), - ] - : projectThreadItems; - pushPaletteView({ - addonIcon: , - groups: [ - { - value: "projects", - label: "Projects", - items: enumerateCommandPaletteItems(prioritized), - }, - ], - }); - }, [ - clearOpenIntent, - currentProjectEnvironmentId, - currentProjectId, - openIntent, - projectThreadItems, - ]); + setNewThreadPickerGeneration((generation) => generation + 1); + }, [clearOpenIntent, newThreadPickerView, openIntent]); const actionItems: Array = []; @@ -1200,38 +1318,11 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + groups: newThreadPickerView.groups, }); } - actionItems.push({ - kind: "action", - value: "action:add-project", - searchTerms: [ - "add project", - "folder", - "directory", - "browse", - "clone", - "remote", - "repository", - "repo", - "git", - "github", - "gitlab", - "bitbucket", - "azure", - "devops", - "url", - "environment", - ], - title: "Add project", - icon: , - keepOpen: true, - run: async () => { - openAddProjectFlow(); - }, - }); + actionItems.push(addProjectActionItem); if (wslAddProjectEnvironmentOption) { actionItems.push({ @@ -1262,10 +1353,11 @@ function OpenCommandPaletteDialog(props: { const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; - const activeGroups = - addProjectEnvironmentId !== null && - currentView !== null && - currentView.groups[0]?.value === sourceSelectionViewValue + const activeGroups = isNewThreadProjectPickerView + ? newThreadPickerGroups + : addProjectEnvironmentId !== null && + currentView !== null && + currentView.groups[0]?.value === sourceSelectionViewValue ? buildAddProjectSourceGroups( addProjectEnvironmentId, buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data), @@ -1895,7 +1987,7 @@ function OpenCommandPaletteDialog(props: { }} > diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index d4d31af3682..f2673232947 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -18,6 +18,7 @@ import { cn } from "~/lib/utils"; interface CommandPaletteResultsProps { emptyStateMessage?: string; + leadingMessage?: string; groups: ReadonlyArray; highlightedItemValue?: string | null; isActionsOnly: boolean; @@ -39,6 +40,9 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { return ( + {props.leadingMessage ? ( +
{props.leadingMessage}
+ ) : null} {props.groups.map((group) => ( {group.label} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index fc07a0cd208..4098da8ac09 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1676,8 +1676,13 @@ export default function SidebarV2() { return; } if (isMobile) setOpenMobile(false); - openCommandPalette({ open: "new-thread-in" }); - }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); + openCommandPalette({ + open: "new-thread-in", + preferredProjectRef: scopedProjectGroup + ? scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id) + : null, + }); + }, [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile]); const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to diff --git a/apps/web/src/lib/chatRouteShortcuts.test.ts b/apps/web/src/lib/chatRouteShortcuts.test.ts new file mode 100644 index 00000000000..213d4c1dd8d --- /dev/null +++ b/apps/web/src/lib/chatRouteShortcuts.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveChatNewShortcutBehavior, + shouldHandleChatRouteShortcut, +} from "./chatRouteShortcuts"; + +describe("shouldHandleChatRouteShortcut", () => { + it("lets chat.new refresh an already-open command palette", () => { + expect(shouldHandleChatRouteShortcut({ command: "chat.new", commandPaletteOpen: true })).toBe( + true, + ); + }); + + it("continues to suppress unrelated chat shortcuts while the palette is open", () => { + expect( + shouldHandleChatRouteShortcut({ command: "chat.newLocal", commandPaletteOpen: true }), + ).toBe(false); + expect(shouldHandleChatRouteShortcut({ command: null, commandPaletteOpen: true })).toBe(false); + }); + + it("handles shortcuts normally while the palette is closed", () => { + expect( + shouldHandleChatRouteShortcut({ command: "chat.newLocal", commandPaletteOpen: false }), + ).toBe(true); + }); +}); + +describe("resolveChatNewShortcutBehavior", () => { + it("opens the project picker when Sidebar V2 has multiple projects", () => { + expect( + resolveChatNewShortcutBehavior({ + sidebarV2Enabled: true, + projectCount: 2, + commandPaletteOpen: true, + }), + ).toBe("open-project-picker"); + }); + + it("opens the picker empty state when Sidebar V2 has no projects", () => { + expect( + resolveChatNewShortcutBehavior({ + sidebarV2Enabled: true, + projectCount: 0, + commandPaletteOpen: false, + }), + ).toBe("open-project-picker"); + }); + + it("creates immediately for Sidebar V1 and single-project setups", () => { + expect( + resolveChatNewShortcutBehavior({ + sidebarV2Enabled: false, + projectCount: 2, + commandPaletteOpen: false, + }), + ).toBe("create-immediately"); + expect( + resolveChatNewShortcutBehavior({ + sidebarV2Enabled: true, + projectCount: 1, + commandPaletteOpen: false, + }), + ).toBe("create-immediately"); + }); + + it("dismisses an open palette before immediate creation", () => { + expect( + resolveChatNewShortcutBehavior({ + sidebarV2Enabled: false, + projectCount: 1, + commandPaletteOpen: true, + }), + ).toBe("dismiss-and-create"); + }); +}); diff --git a/apps/web/src/lib/chatRouteShortcuts.ts b/apps/web/src/lib/chatRouteShortcuts.ts new file mode 100644 index 00000000000..529298fa61f --- /dev/null +++ b/apps/web/src/lib/chatRouteShortcuts.ts @@ -0,0 +1,19 @@ +import type { KeybindingCommand } from "@t3tools/contracts"; + +export function shouldHandleChatRouteShortcut(input: { + readonly command: KeybindingCommand | null; + readonly commandPaletteOpen: boolean; +}): boolean { + return !input.commandPaletteOpen || input.command === "chat.new"; +} + +export function resolveChatNewShortcutBehavior(input: { + readonly sidebarV2Enabled: boolean; + readonly projectCount: number; + readonly commandPaletteOpen: boolean; +}): "open-project-picker" | "create-immediately" | "dismiss-and-create" { + if (input.sidebarV2Enabled && input.projectCount !== 1) { + return "open-project-picker"; + } + return input.commandPaletteOpen ? "dismiss-and-create" : "create-immediately"; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 2e29d222b5b..47d74661d7c 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -2,9 +2,12 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import { useEffect, useMemo } from "react"; -import { isCommandPaletteOpen } from "../commandPaletteBus"; +import { + closeCommandPalette, + isCommandPaletteOpen, + openCommandPalette, +} from "../commandPaletteBus"; import { useClientSettings } from "../hooks/useSettings"; -import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; import { selectProjectGroupingSettings } from "../logicalProject"; @@ -12,9 +15,14 @@ import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { + resolveThreadActionProjectRef, startNewLocalThreadFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; +import { + resolveChatNewShortcutBehavior, + shouldHandleChatRouteShortcut, +} from "../lib/chatRouteShortcuts"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; @@ -70,7 +78,8 @@ function ChatRouteGlobalShortcuts() { }, }); - if (isCommandPaletteOpen()) { + const commandPaletteOpen = isCommandPaletteOpen(); + if (!shouldHandleChatRouteShortcut({ command, commandPaletteOpen })) { return; } @@ -98,10 +107,26 @@ function ChatRouteGlobalShortcuts() { // 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 && projectGroupCount > 1) { - openCommandPalette({ open: "new-thread-in" }); + const behavior = resolveChatNewShortcutBehavior({ + sidebarV2Enabled, + projectCount: projectGroupCount, + commandPaletteOpen, + }); + if (behavior === "open-project-picker") { + openCommandPalette({ + open: "new-thread-in", + preferredProjectRef: resolveThreadActionProjectRef({ + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }), + }); return; } + if (behavior === "dismiss-and-create") { + closeCommandPalette(); + } void startNewThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined,