From a8090f9048a8f357cf6f2bc740b8c75266fc5c77 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:20:42 -0700 Subject: [PATCH 1/3] feat(web): preserve new-thread picker behaviors --- apps/web/src/commandPaletteBus.ts | 3 + .../components/CommandPalette.logic.test.ts | 91 +++++++ .../src/components/CommandPalette.logic.ts | 46 +++- apps/web/src/components/CommandPalette.tsx | 226 +++++++++++------- .../src/components/CommandPaletteResults.tsx | 50 ++-- apps/web/src/components/SidebarV2.tsx | 9 +- apps/web/src/lib/chatRouteShortcuts.test.ts | 24 ++ apps/web/src/lib/chatRouteShortcuts.ts | 8 + apps/web/src/routes/_chat.tsx | 19 +- 9 files changed, 369 insertions(+), 107 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..7a5cf128ba7 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -1,9 +1,12 @@ +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"; export interface CommandPaletteOpenDetail { readonly open?: "add-project" | "new-thread-in"; + readonly preferredProjectRef?: ScopedProjectRef | null; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 902b7e87773..b279451a51f 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + buildNewThreadPickerGroups, buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + prioritizeScopedProject, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -38,6 +41,94 @@ describe("enumerateCommandPaletteItems", () => { const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +describe("prioritizeScopedProject", () => { + it("moves the preferred scoped project to the front without disturbing the remaining order", () => { + const otherEnvironmentId = EnvironmentId.make("environment-remote"); + const otherProjectId = ProjectId.make("project-2"); + const preferredProjectId = ProjectId.make("project-3"); + const projects = [ + { environmentId: LOCAL_ENVIRONMENT_ID, id: PROJECT_ID }, + { environmentId: otherEnvironmentId, id: otherProjectId }, + { environmentId: LOCAL_ENVIRONMENT_ID, id: preferredProjectId }, + ]; + + expect( + prioritizeScopedProject(projects, scopeProjectRef(LOCAL_ENVIRONMENT_ID, preferredProjectId)), + ).toEqual([projects[2], projects[0], projects[1]]); + }); + + it("preserves order when the preferred scoped project is unavailable", () => { + const projects = [{ environmentId: LOCAL_ENVIRONMENT_ID, id: PROJECT_ID }]; + + expect( + prioritizeScopedProject( + projects, + scopeProjectRef(LOCAL_ENVIRONMENT_ID, ProjectId.make("missing-project")), + ), + ).toEqual(projects); + }); +}); + +describe("buildNewThreadPickerGroups", () => { + const addProjectItem = { + kind: "action" as const, + value: "action:add-project", + searchTerms: ["add project"], + title: "Add project", + icon: null, + run: async () => undefined, + }; + + it("keeps Add project available when there are no projects", () => { + expect(buildNewThreadPickerGroups({ projectItems: [], addProjectItem })).toEqual([ + { value: "new-thread-actions", label: "Actions", items: [addProjectItem] }, + ]); + }); + + it("stays empty while projects are still loading", () => { + expect( + buildNewThreadPickerGroups({ projectItems: [], addProjectItem, areProjectsLoading: true }), + ).toEqual([]); + }); + + it("keeps Add project available during partial loading when projects are visible", () => { + const projectItem = { + ...addProjectItem, + value: "new-thread-in:environment-local:project-1", + title: "Project", + }; + + expect( + buildNewThreadPickerGroups({ + projectItems: [projectItem], + addProjectItem, + areProjectsLoading: true, + }).map((group) => group.value), + ).toEqual(["new-thread-projects", "new-thread-actions"]); + }); + + it("places Add project after available project choices", () => { + const projectItem = { + ...addProjectItem, + value: "new-thread-in:environment-local:project-1", + title: "Project", + }; + + expect( + buildNewThreadPickerGroups({ projectItems: [projectItem], addProjectItem }).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"] }, + ]); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index a217d53b5b7..323f003633b 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,6 +1,7 @@ import { - type KeybindingCommand, type FilesystemBrowseEntry, + type KeybindingCommand, + type ScopedProjectRef, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; @@ -127,6 +128,34 @@ export function buildProjectActionItems(input: { })); } +export function prioritizeScopedProject>( + projects: ReadonlyArray, + preferredProjectRef: ScopedProjectRef | null, +): T[] { + if (preferredProjectRef === null) { + return [...projects]; + } + + const preferredIndex = projects.findIndex( + (project) => + project.environmentId === preferredProjectRef.environmentId && + project.id === preferredProjectRef.projectId, + ); + if (preferredIndex <= 0) { + return [...projects]; + } + + const preferredProject = projects[preferredIndex]; + if (preferredProject === undefined) { + return [...projects]; + } + return [ + preferredProject, + ...projects.slice(0, preferredIndex), + ...projects.slice(preferredIndex + 1), + ]; +} + export type BuildThreadActionItemsThread = Pick< SidebarThreadSummary, "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" @@ -365,6 +394,21 @@ export function buildRootGroups(input: { return groups; } +export function buildNewThreadPickerGroups(input: { + projectItems: ReadonlyArray; + addProjectItem: CommandPaletteActionItem; + areProjectsLoading?: boolean; +}): CommandPaletteGroup[] { + const groups: CommandPaletteGroup[] = []; + if (input.projectItems.length > 0) { + groups.push({ value: "new-thread-projects", label: "Projects", items: input.projectItems }); + } + if (input.projectItems.length > 0 || input.areProjectsLoading !== true) { + groups.push({ value: "new-thread-actions", label: "Actions", items: [input.addProjectItem] }); + } + return groups; +} + export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): string { switch (mode) { case "root": diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index bb83dfa5691..8e1eb4ee298 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,8 +58,13 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useThreadShells, +} from "../state/entities"; +import { + resolveThreadActionProjectRef, startNewThreadInProjectFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; @@ -92,6 +98,7 @@ import { import { ADDON_ICON_CLASS, buildBrowseGroups, + buildNewThreadPickerGroups, buildProjectActionItems, buildRootGroups, buildThreadActionItems, @@ -104,6 +111,7 @@ import { getCommandPaletteInputPlaceholder, getCommandPaletteMode, ITEM_ICON_CLASS, + prioritizeScopedProject, RECENT_THREAD_LIMIT, } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -130,6 +138,7 @@ import { ComposerHandleContext, useComposerHandleContext } from "../composerHand import type { ChatComposerHandle } from "./chat/ChatComposer"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +const NEW_THREAD_PROJECT_VIEW_GROUP = "new-thread-projects"; function getLocalFileManagerName(platform: string): string { if (isMacPlatform(platform)) { @@ -335,9 +344,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; @@ -348,7 +360,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( @@ -366,7 +381,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; } @@ -380,7 +401,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); @@ -419,7 +447,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 { @@ -491,11 +519,16 @@ function OpenCommandPaletteDialog(props: { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); + const allEnvironmentShellsBootstrapped = useAllEnvironmentShellsBootstrapped(); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; + const isNewThreadProjectView = 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, @@ -685,33 +718,65 @@ function OpenCommandPaletteDialog(props: { [openProjectFromSearch, projects], ); + const contextualNewThreadProjectRef = useMemo( + () => + resolveThreadActionProjectRef({ + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }), + [activeDraftThread, activeThread, defaultProjectRef, handleNewThread], + ); + const buildProjectThreadItems = useCallback( + (candidateProjects: typeof projects) => + buildProjectActionItems({ + projects: candidateProjects, + valuePrefix: "new-thread-in", + icon: (project) => ( + + ), + runProject: async (project) => { + await startNewThreadInProjectFromContext( + { + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + }), + [activeDraftThread, activeThread, defaultProjectRef, handleNewThread], + ); const projectThreadItems = useMemo( () => enumerateCommandPaletteItems( - buildProjectActionItems({ - projects, - valuePrefix: "new-thread-in", - icon: (project) => ( - + buildProjectThreadItems(prioritizeScopedProject(projects, contextualNewThreadProjectRef)), + ), + [buildProjectThreadItems, contextualNewThreadProjectRef, projects], + ); + const newThreadPickerItems = useMemo( + () => + enumerateCommandPaletteItems( + buildProjectThreadItems( + prioritizeScopedProject( + projects, + newThreadPreferredProjectRef ?? contextualNewThreadProjectRef, ), - runProject: async (project) => { - await startNewThreadInProjectFromContext( - { - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }, - scopeProjectRef(project.environmentId, project.id), - ); - }, - }), + ), ), - [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], + [ + buildProjectThreadItems, + contextualNewThreadProjectRef, + newThreadPreferredProjectRef, + projects, + ], ); const allThreadItems = useMemo( @@ -980,40 +1045,27 @@ 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(); + // The new-thread intent takes ownership of an already-open palette, so + // clear clone-only state before displaying the dedicated project picker. setAddProjectCloneFlow(null); - setViewStack([]); - 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; + setIsRemoteProjectLookingUp(false); + setIsRemoteProjectCloning(false); + if (isNewThreadProjectView) { + setHighlightedItemValue(null); + setQuery(""); + setNewThreadPickerGeneration((generation) => generation + 1); + return; + } pushPaletteView({ addonIcon: , - groups: [ - { - value: "projects", - label: "Projects", - items: enumerateCommandPaletteItems(prioritized), - }, - ], + groups: [{ value: NEW_THREAD_PROJECT_VIEW_GROUP, label: "Projects", items: [] }], }); - }, [ - clearOpenIntent, - currentProjectEnvironmentId, - currentProjectId, - openIntent, - projectThreadItems, - ]); + }, [clearOpenIntent, isNewThreadProjectView, openIntent]); const actionItems: Array = []; @@ -1033,7 +1085,6 @@ function OpenCommandPaletteDialog(props: { ), icon: , - shortcutCommand: "chat.new", run: async () => { await startNewThreadFromContext({ activeDraftThread, @@ -1056,7 +1107,7 @@ function OpenCommandPaletteDialog(props: { }); } - actionItems.push({ + const addProjectActionItem: CommandPaletteActionItem = { kind: "action", value: "action:add-project", searchTerms: [ @@ -1083,7 +1134,8 @@ function OpenCommandPaletteDialog(props: { run: async () => { openAddProjectFlow(); }, - }); + }; + actionItems.push(addProjectActionItem); if (wslAddProjectEnvironmentOption) { actionItems.push({ @@ -1114,10 +1166,15 @@ 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 = isNewThreadProjectView + ? buildNewThreadPickerGroups({ + projectItems: newThreadPickerItems, + addProjectItem: addProjectActionItem, + areProjectsLoading: !allEnvironmentShellsBootstrapped, + }) + : addProjectEnvironmentId !== null && + currentView !== null && + currentView.groups[0]?.value === sourceSelectionViewValue ? buildAddProjectSourceGroups( addProjectEnvironmentId, buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data), @@ -1747,7 +1804,7 @@ function OpenCommandPaletteDialog(props: { }} > diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index d4d31af3682..049eccb7573 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; @@ -38,28 +39,33 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { } return ( - - {props.groups.map((group) => ( - - {group.label} - - {(item) => - item.disabled ? ( - - ) : ( - - ) - } - - - ))} - + <> + {props.leadingMessage ? ( +
{props.leadingMessage}
+ ) : null} + + {props.groups.map((group) => ( + + {group.label} + + {(item) => + item.disabled ? ( + + ) : ( + + ) + } + + + ))} + + ); } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 2b81c15c7ad..c021f5829e0 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1447,8 +1447,13 @@ export default function SidebarV2() { return; } if (isMobile) setOpenMobile(false); - openCommandPalette({ open: "new-thread-in" }); - }, [isMobile, newThreadContext, projects.length, setOpenMobile]); + openCommandPalette({ + open: "new-thread-in", + preferredProjectRef: scopedProject + ? scopeProjectRef(scopedProject.environmentId, scopedProject.id) + : null, + }); + }, [isMobile, newThreadContext, projects.length, scopedProject, 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..abe2cd9c27f --- /dev/null +++ b/apps/web/src/lib/chatRouteShortcuts.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { 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); + }); +}); diff --git a/apps/web/src/lib/chatRouteShortcuts.ts b/apps/web/src/lib/chatRouteShortcuts.ts new file mode 100644 index 00000000000..62c33661f05 --- /dev/null +++ b/apps/web/src/lib/chatRouteShortcuts.ts @@ -0,0 +1,8 @@ +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"; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 57824fe5188..e9c1441cb0e 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -9,9 +9,11 @@ import { useProjects } from "../state/entities"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { + resolveThreadActionProjectRef, startNewLocalThreadFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; +import { shouldHandleChatRouteShortcut } from "../lib/chatRouteShortcuts"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; @@ -55,7 +57,12 @@ function ChatRouteGlobalShortcuts() { }, }); - if (isCommandPaletteOpen()) { + if ( + !shouldHandleChatRouteShortcut({ + command, + commandPaletteOpen: isCommandPaletteOpen(), + }) + ) { return; } @@ -84,7 +91,15 @@ function ChatRouteGlobalShortcuts() { // there is a real choice to make; v1 (and single-project setups) // keep the immediate contextual create. if (sidebarV2Enabled && projectCount > 1) { - openCommandPalette({ open: "new-thread-in" }); + openCommandPalette({ + open: "new-thread-in", + preferredProjectRef: resolveThreadActionProjectRef({ + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }), + }); return; } void startNewThreadFromContext({ From 6f52b888008c8d0276d43ed2b93d6a335ff8e8f2 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:36:01 -0700 Subject: [PATCH 2/3] Reset palette stack for new thread intent --- .../components/CommandPalette.logic.test.ts | 40 +++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 21 +++++++++- apps/web/src/components/CommandPalette.tsx | 33 ++++++++------- 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index b279451a51f..1a5a58faf7d 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -7,8 +7,11 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + NEW_THREAD_PROJECT_VIEW_GROUP, prioritizeScopedProject, + resolveNewThreadIntentViewStack, type CommandPaletteGroup, + type CommandPaletteView, } from "./CommandPalette.logic"; describe("enumerateCommandPaletteItems", () => { @@ -41,6 +44,43 @@ describe("enumerateCommandPaletteItems", () => { const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +describe("resolveNewThreadIntentViewStack", () => { + const newThreadView: CommandPaletteView = { + addonIcon: null, + groups: [{ value: NEW_THREAD_PROJECT_VIEW_GROUP, label: "Projects", items: [] }], + }; + + it("replaces another submenu instead of stacking on it", () => { + const rootView: CommandPaletteView = { + addonIcon: null, + groups: [{ value: "root", label: "Root", items: [] }], + }; + const staleView: CommandPaletteView = { + addonIcon: null, + groups: [{ value: "add-project", label: "Add project", items: [] }], + }; + + expect(resolveNewThreadIntentViewStack([rootView, staleView], newThreadView)).toEqual([ + newThreadView, + ]); + }); + + it("keeps the active picker for refresh while discarding stale back-stack views", () => { + const staleView: CommandPaletteView = { + addonIcon: null, + groups: [{ value: "browse", label: "Browse", items: [] }], + }; + const currentNewThreadView: CommandPaletteView = { + ...newThreadView, + initialQuery: "preserved-current-view", + }; + + expect( + resolveNewThreadIntentViewStack([staleView, currentNewThreadView], newThreadView), + ).toEqual([currentNewThreadView]); + }); +}); + describe("prioritizeScopedProject", () => { it("moves the preferred scoped project to the front without disturbing the remaining order", () => { const otherEnvironmentId = EnvironmentId.make("environment-remote"); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 323f003633b..d5fec4b9cd7 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,6 +15,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"; @@ -69,6 +70,20 @@ export function enumerateCommandPaletteItems( }); } +export function isNewThreadProjectView(view: CommandPaletteView | null): boolean { + return view?.groups[0]?.value === NEW_THREAD_PROJECT_VIEW_GROUP; +} + +export function resolveNewThreadIntentViewStack( + currentStack: ReadonlyArray, + newThreadView: CommandPaletteView, +): CommandPaletteView[] { + const currentView = currentStack.at(-1) ?? null; + return [ + currentView !== null && isNewThreadProjectView(currentView) ? currentView : newThreadView, + ]; +} + export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; export function filterBrowseEntries(input: { @@ -401,7 +416,11 @@ export function buildNewThreadPickerGroups(input: { }): CommandPaletteGroup[] { const groups: CommandPaletteGroup[] = []; if (input.projectItems.length > 0) { - groups.push({ value: "new-thread-projects", label: "Projects", items: input.projectItems }); + groups.push({ + value: NEW_THREAD_PROJECT_VIEW_GROUP, + label: "Projects", + items: input.projectItems, + }); } if (input.projectItems.length > 0 || input.areProjectsLoading !== true) { groups.push({ value: "new-thread-actions", label: "Actions", items: [input.addProjectItem] }); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8e1eb4ee298..985d25cfa7e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -110,9 +110,12 @@ import { filterCommandPaletteGroups, getCommandPaletteInputPlaceholder, getCommandPaletteMode, + isNewThreadProjectView, ITEM_ICON_CLASS, + NEW_THREAD_PROJECT_VIEW_GROUP, prioritizeScopedProject, RECENT_THREAD_LIMIT, + resolveNewThreadIntentViewStack, } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; @@ -138,8 +141,6 @@ import { ComposerHandleContext, useComposerHandleContext } from "../composerHand import type { ChatComposerHandle } from "./chat/ChatComposer"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; -const NEW_THREAD_PROJECT_VIEW_GROUP = "new-thread-projects"; - function getLocalFileManagerName(platform: string): string { if (isMacPlatform(platform)) { return "Finder"; @@ -525,7 +526,7 @@ function OpenCommandPaletteDialog(props: { const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; - const isNewThreadProjectView = currentView?.groups[0]?.value === NEW_THREAD_PROJECT_VIEW_GROUP; + const isNewThreadProjectPickerView = isNewThreadProjectView(currentView); const [newThreadPreferredProjectRef, setNewThreadPreferredProjectRef] = useState(null); const [newThreadPickerGeneration, setNewThreadPickerGeneration] = useState(0); @@ -1055,17 +1056,17 @@ function OpenCommandPaletteDialog(props: { setAddProjectCloneFlow(null); setIsRemoteProjectLookingUp(false); setIsRemoteProjectCloning(false); - if (isNewThreadProjectView) { - setHighlightedItemValue(null); - setQuery(""); - setNewThreadPickerGeneration((generation) => generation + 1); - return; - } - pushPaletteView({ + const newThreadView = { addonIcon: , groups: [{ value: NEW_THREAD_PROJECT_VIEW_GROUP, label: "Projects", items: [] }], - }); - }, [clearOpenIntent, isNewThreadProjectView, openIntent]); + } satisfies CommandPaletteView; + setViewStack((currentStack) => resolveNewThreadIntentViewStack(currentStack, newThreadView)); + setHighlightedItemValue(null); + setQuery(""); + if (isNewThreadProjectPickerView) { + setNewThreadPickerGeneration((generation) => generation + 1); + } + }, [clearOpenIntent, isNewThreadProjectPickerView, openIntent]); const actionItems: Array = []; @@ -1166,7 +1167,7 @@ function OpenCommandPaletteDialog(props: { const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; - const activeGroups = isNewThreadProjectView + const activeGroups = isNewThreadProjectPickerView ? buildNewThreadPickerGroups({ projectItems: newThreadPickerItems, addProjectItem: addProjectActionItem, @@ -1949,10 +1950,12 @@ function OpenCommandPaletteDialog(props: { isActionsOnly={isActionsOnly} keybindings={keybindings} onExecuteItem={executeItem} - {...(isNewThreadProjectView && allEnvironmentShellsBootstrapped && projects.length === 0 + {...(isNewThreadProjectPickerView && + allEnvironmentShellsBootstrapped && + projects.length === 0 ? { leadingMessage: "No projects yet. Add a project to start a thread." } : {})} - {...(isNewThreadProjectView && projects.length === 0 + {...(isNewThreadProjectPickerView && projects.length === 0 ? { emptyStateMessage: allEnvironmentShellsBootstrapped ? "No projects yet. Add a project to start a thread." From 6e0d514c884e1bfdd5d510f5800f2413c762e915 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 16:43:09 -0700 Subject: [PATCH 3/3] fix(web): finish unified new-thread picker flows --- apps/web/src/commandPaletteBus.ts | 10 +++++ .../components/CommandPalette.logic.test.ts | 14 ++++-- .../src/components/CommandPalette.logic.ts | 7 +++ apps/web/src/components/CommandPalette.tsx | 20 +++------ apps/web/src/lib/chatRouteShortcuts.test.ts | 44 ++++++++++++++++++- apps/web/src/lib/chatRouteShortcuts.ts | 11 +++++ apps/web/src/routes/_chat.tsx | 25 ++++++++--- 7 files changed, 107 insertions(+), 24 deletions(-) diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 7a5cf128ba7..22823339898 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -3,6 +3,7 @@ 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"; @@ -25,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 1a5a58faf7d..2f7f3fd87fd 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -3,10 +3,12 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + buildNewThreadProjectView, buildNewThreadPickerGroups, buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + isNewThreadProjectView, NEW_THREAD_PROJECT_VIEW_GROUP, prioritizeScopedProject, resolveNewThreadIntentViewStack, @@ -45,10 +47,14 @@ const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); describe("resolveNewThreadIntentViewStack", () => { - const newThreadView: CommandPaletteView = { - addonIcon: null, - groups: [{ value: NEW_THREAD_PROJECT_VIEW_GROUP, label: "Projects", items: [] }], - }; + const newThreadView = buildNewThreadProjectView(null); + + it("marks the shared view as the dynamic new-thread project picker", () => { + expect(newThreadView.groups).toEqual([ + { value: NEW_THREAD_PROJECT_VIEW_GROUP, label: "Projects", items: [] }, + ]); + expect(isNewThreadProjectView(newThreadView)).toBe(true); + }); it("replaces another submenu instead of stacking on it", () => { const rootView: CommandPaletteView = { diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index d5fec4b9cd7..2bef1fb7d9b 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -74,6 +74,13 @@ export function isNewThreadProjectView(view: CommandPaletteView | null): boolean return view?.groups[0]?.value === NEW_THREAD_PROJECT_VIEW_GROUP; } +export function buildNewThreadProjectView(addonIcon: ReactNode): CommandPaletteView { + return { + addonIcon, + groups: [{ value: NEW_THREAD_PROJECT_VIEW_GROUP, label: "Projects", items: [] }], + }; +} + export function resolveNewThreadIntentViewStack( currentStack: ReadonlyArray, newThreadView: CommandPaletteView, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 985d25cfa7e..b0cf4d5403f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -83,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 } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; @@ -98,6 +98,7 @@ import { import { ADDON_ICON_CLASS, buildBrowseGroups, + buildNewThreadProjectView, buildNewThreadPickerGroups, buildProjectActionItems, buildRootGroups, @@ -112,7 +113,6 @@ import { getCommandPaletteMode, isNewThreadProjectView, ITEM_ICON_CLASS, - NEW_THREAD_PROJECT_VIEW_GROUP, prioritizeScopedProject, RECENT_THREAD_LIMIT, resolveNewThreadIntentViewStack, @@ -458,6 +458,8 @@ export function CommandPalette({ children }: { children: ReactNode }) { [openAddProject, openNewThreadIn, setOpen], ); + useEffect(() => onCloseCommandPalette(() => setOpen(false)), [setOpen]); + return ( @@ -755,13 +757,6 @@ function OpenCommandPaletteDialog(props: { }), [activeDraftThread, activeThread, defaultProjectRef, handleNewThread], ); - const projectThreadItems = useMemo( - () => - enumerateCommandPaletteItems( - buildProjectThreadItems(prioritizeScopedProject(projects, contextualNewThreadProjectRef)), - ), - [buildProjectThreadItems, contextualNewThreadProjectRef, projects], - ); const newThreadPickerItems = useMemo( () => enumerateCommandPaletteItems( @@ -1056,10 +1051,7 @@ function OpenCommandPaletteDialog(props: { setAddProjectCloneFlow(null); setIsRemoteProjectLookingUp(false); setIsRemoteProjectCloning(false); - const newThreadView = { - addonIcon: , - groups: [{ value: NEW_THREAD_PROJECT_VIEW_GROUP, label: "Projects", items: [] }], - } satisfies CommandPaletteView; + const newThreadView = buildNewThreadProjectView(); setViewStack((currentStack) => resolveNewThreadIntentViewStack(currentStack, newThreadView)); setHighlightedItemValue(null); setQuery(""); @@ -1104,7 +1096,7 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + groups: buildNewThreadProjectView().groups, }); } diff --git a/apps/web/src/lib/chatRouteShortcuts.test.ts b/apps/web/src/lib/chatRouteShortcuts.test.ts index abe2cd9c27f..4780e54f3ae 100644 --- a/apps/web/src/lib/chatRouteShortcuts.test.ts +++ b/apps/web/src/lib/chatRouteShortcuts.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { shouldHandleChatRouteShortcut } from "./chatRouteShortcuts"; +import { + resolveChatNewShortcutBehavior, + shouldHandleChatRouteShortcut, +} from "./chatRouteShortcuts"; describe("shouldHandleChatRouteShortcut", () => { it("lets chat.new refresh an already-open command palette", () => { @@ -22,3 +25,42 @@ describe("shouldHandleChatRouteShortcut", () => { ).toBe(true); }); }); + +describe("resolveChatNewShortcutBehavior", () => { + it("opens the project picker when Sidebar V2 has a real project choice", () => { + expect( + resolveChatNewShortcutBehavior({ + sidebarV2Enabled: true, + projectCount: 2, + commandPaletteOpen: true, + }), + ).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 index 62c33661f05..029c4091df2 100644 --- a/apps/web/src/lib/chatRouteShortcuts.ts +++ b/apps/web/src/lib/chatRouteShortcuts.ts @@ -6,3 +6,14 @@ export function shouldHandleChatRouteShortcut(input: { }): 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 e9c1441cb0e..a04481a294d 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 } 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 { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -13,7 +16,10 @@ import { startNewLocalThreadFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; -import { shouldHandleChatRouteShortcut } from "../lib/chatRouteShortcuts"; +import { + resolveChatNewShortcutBehavior, + shouldHandleChatRouteShortcut, +} from "../lib/chatRouteShortcuts"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; @@ -57,10 +63,11 @@ function ChatRouteGlobalShortcuts() { }, }); + const commandPaletteOpen = isCommandPaletteOpen(); if ( !shouldHandleChatRouteShortcut({ command, - commandPaletteOpen: isCommandPaletteOpen(), + commandPaletteOpen, }) ) { return; @@ -90,7 +97,12 @@ 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 && projectCount > 1) { + const behavior = resolveChatNewShortcutBehavior({ + sidebarV2Enabled, + projectCount, + commandPaletteOpen, + }); + if (behavior === "open-project-picker") { openCommandPalette({ open: "new-thread-in", preferredProjectRef: resolveThreadActionProjectRef({ @@ -102,6 +114,9 @@ function ChatRouteGlobalShortcuts() { }); return; } + if (behavior === "dismiss-and-create") { + closeCommandPalette(); + } void startNewThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined,