diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 4abd2a4655d1c..101759fc6c42f 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -348,31 +348,6 @@ function makeThread(overrides: Partial = {}): Thread { } describe("buildProjectActionItems", () => { - it("shows offline checkout rows but excludes them from keyboard selection", () => { - const local = makeProject(); - const remote = makeProject({ - id: local.id, - environmentId: EnvironmentId.make("offline"), - }); - const items = buildProjectActionItems({ - projects: [remote, local].map((project) => ({ ...project, displayName: "shared-repo" })), - valuePrefix: "new-thread-in", - icon: () => null, - runProject: async () => undefined, - disabled: (project) => project.environmentId === remote.environmentId, - }); - const groups = [{ value: "projects", label: "Projects", items }]; - const { rows, itemValues } = buildCommandPaletteRows(groups); - - expect(rows.filter((row) => row.kind === "item").map((row) => row.item.value)).toEqual([ - `new-thread-in:${remote.environmentId}:${remote.id}`, - `new-thread-in:${local.environmentId}:${local.id}`, - ]); - expect(itemValues).toEqual([`new-thread-in:${local.environmentId}:${local.id}`]); - expect(findHighlightedCommandPaletteItem(groups, items[0]!.value)).toBeNull(); - expect(findHighlightedCommandPaletteItem(groups, items[1]!.value)).toBe(items[1]); - }); - it("shows the grouped display name but keeps the real title for icons", () => { const project = makeProject({ title: "fleet", workspaceRoot: "/Users/theo/Code/p/fleet" }); const iconTitles: string[] = []; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 9da8c940be333..3037ef72159df 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -315,7 +315,6 @@ export function buildProjectActionItems(input: { runProject: (project: CommandPaletteProject) => Promise; searchTerms?: (project: CommandPaletteProject) => ReadonlyArray; renderDescription?: (project: CommandPaletteProject) => ReactNode; - disabled?: (project: CommandPaletteProject) => boolean; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ @@ -330,7 +329,6 @@ export function buildProjectActionItems(input: { title: project.displayName, description: input.renderDescription?.(project) ?? project.workspaceRoot, icon: input.icon(project), - ...(input.disabled ? { disabled: input.disabled(project) } : {}), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { await input.runProject(project); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 14078841086c7..dd6db0fc6ac52 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -25,7 +25,7 @@ import { type AddProjectRemoteSourceReadiness, } from "@t3tools/client-runtime/operations/projects"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { @@ -887,20 +887,6 @@ function OpenCommandPaletteDialog(props: { groups: projectGroups, preferredProjectRef: contextualProjectRef, }); - const isEnvironmentReachable = (environmentId: EnvironmentId) => - environments.some( - (environment) => - environment.environmentId === environmentId && environment.connection.phase === "connected", - ); - const threadPickerProjects = buildSidebarProjectPickerEntries({ - groups: projectGroups, - preferredProjectRef: contextualProjectRef, - expandCheckouts: true, - isEnvironmentReachable, - }).map(({ group, targetProject }) => ({ - ...targetProject, - displayName: group.displayName, - })); const pickerProjects = projectPickerEntries.map(({ group, targetProject }) => ({ ...targetProject, displayName: group.displayName, @@ -1177,16 +1163,18 @@ function OpenCommandPaletteDialog(props: { isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); const projectItems = enumerateCommandPaletteItems( buildProjectActionItems({ - projects: threadPickerProjects.filter((project) => !isScratch(project)), + projects: pickerProjects.filter((project) => !isScratch(project)), valuePrefix: "new-thread-in", - disabled: (project) => !isEnvironmentReachable(project.environmentId), - searchTerms: (project) => [ - projectEnvironmentLocationById.get(project.environmentId)?.label ?? "Remote", - ], + searchTerms: (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const location = projectEnvironmentLocationById.get(project.environmentId); + return [ + ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? + []), + ...(location ? [location.label] : []), + ]; + }, renderDescription: (project) => { - const environment = environments.find( - (candidate) => candidate.environmentId === project.environmentId, - ); const location = projectEnvironmentLocationById.get(project.environmentId) ?? { kind: "remote", label: "Remote", @@ -1206,29 +1194,30 @@ function OpenCommandPaletteDialog(props: { {project.workspaceRoot} - {isEnvironmentReachable(project.environmentId) ? null : ( - <> - - - {environment ? connectionStatusTitle(environment.connection) : "Unavailable"} - - - )} ); }, icon: projectFaviconIcon, runProject: async (project) => { - await handleNewThread(scopeProjectRef(project.environmentId, project.id), { - environmentSelection: "manual", - }); + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const contextualRefBelongsToGroup = + contextualProjectRef !== null && + group?.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === contextualProjectRef.environmentId && + projectRef.projectId === contextualProjectRef.projectId, + ); + await handleNewThread( + contextualRefBelongsToGroup + ? contextualProjectRef + : scopeProjectRef(project.environmentId, project.id), + ); }, }), ); if (scratchTargetEnvironmentId === null) return projectItems; - const noProjectIndex = - threadPickerProjects[0] !== undefined && isScratch(threadPickerProjects[0]) ? 0 : 1; + const noProjectIndex = pickerProjects[0] !== undefined && isScratch(pickerProjects[0]) ? 0 : 1; return [ ...projectItems.slice(0, noProjectIndex), { @@ -1631,7 +1620,7 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }], + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); } @@ -1978,7 +1967,7 @@ function OpenCommandPaletteDialog(props: { case "new-thread-in": pushPaletteView({ addonIcon: , - groups: [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }], + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); return; case "change-theme": @@ -2125,9 +2114,7 @@ function OpenCommandPaletteDialog(props: { ? changeThemeItem.groups : currentView?.groups[0]?.value === "appearance" ? changeAppearanceItem.groups - : currentView?.groups[0]?.value === "new-thread-in" - ? [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }] - : (currentView?.groups ?? rootGroups); + : (currentView?.groups ?? rootGroups); const filteredGroups = filterCommandPaletteGroups({ activeGroups, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 22910c76d5faa..b036590edd058 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -130,7 +130,6 @@ import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { selectProjectGroupingSettings } from "../logicalProject"; import { - countSidebarProjectChoices, projectGroupsSpanEnvironments, resolveSidebarProjectScopeKey, getSidebarProjectSettingsKey, @@ -4531,9 +4530,8 @@ export default function Sidebar() { updateThreadJumpHintsVisibility(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); - const projectChoiceCount = countSidebarProjectChoices(projectGroups); const handleNewThreadClick = (event?: ReactMouseEvent) => { - if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectChoiceCount)) { + if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, @@ -4549,7 +4547,7 @@ export default function Sidebar() { const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectChoiceCount <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -4691,7 +4689,7 @@ export default function Sidebar() { newThreadDisabled={projects.length === 0} newThreadShortcutLabel={newThreadShortcutLabel} newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} - showNewThreadInProjectHint={projectChoiceCount > 1} + showNewThreadInProjectHint={projectGroups.length > 1} searchInputRef={threadSearchInputRef} searchQuery={threadSearchQuery} onSearchQueryChange={(value) => { diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index eba9f1f9a9a54..5be4dc5c26122 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -12,7 +12,6 @@ import { buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, - countSidebarProjectChoices, projectGroupsSpanEnvironments, resolveSidebarProjectScopeKey, getSidebarProjectSettingsKey, @@ -78,10 +77,6 @@ describe("environment grouping", () => { const settingsGroups = buildSidebarProjectSnapshots(input); expect(filters).toHaveLength(1); expect(settingsGroups).toHaveLength(2); - expect([ - countSidebarProjectChoices(filters), - countSidebarProjectChoices(settingsGroups), - ]).toEqual([1, 1]); expect(settingsGroups.map((group) => group.memberProjectRefs)).toEqual([ [{ environmentId: local.environmentId, projectId: local.id }], [{ environmentId: remote.environmentId, projectId: remote.id }], @@ -570,53 +565,6 @@ describe("environment grouping", () => { }); }); - it("offers every physical checkout, with reachable choices before an offline preference", () => { - const primary = makeProject({ repositoryIdentity }); - const worktree = makeProject({ - id: ProjectId.make("local-worktree"), - workspaceRoot: "/tmp/shared-repo-feature", - repositoryIdentity, - }); - const remote = makeProject({ - id: worktree.id, - environmentId: remoteEnvironmentId, - repositoryIdentity, - }); - const groups = buildSidebarProjectSnapshots({ - projects: [remote, primary, worktree], - settings: defaultGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: () => null, - }); - expect(countSidebarProjectChoices(groups)).toBe(3); - const pickerInput = { - groups, - expandCheckouts: true, - isEnvironmentReachable: (environmentId: EnvironmentId) => - environmentId === primaryEnvironmentId, - }; - - const connectedPreference = buildSidebarProjectPickerEntries({ - ...pickerInput, - preferredProjectRef: { environmentId: primaryEnvironmentId, projectId: worktree.id }, - }); - expect(connectedPreference.map(({ targetProject }) => targetProject)).toMatchObject([ - worktree, - primary, - remote, - ]); - - const offlinePreference = buildSidebarProjectPickerEntries({ - ...pickerInput, - preferredProjectRef: { environmentId: remoteEnvironmentId, projectId: remote.id }, - }); - expect(offlinePreference.map(({ targetProject }) => targetProject)).toMatchObject([ - primary, - worktree, - remote, - ]); - }); - it("keeps manual project order when building grouped sidebar entries", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index b6057f3824dc5..3fa77ce4b9ce4 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -103,14 +103,12 @@ vi.mock("@effect/atom-react", () => ({ ? { newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin } : testServerConfigs(), })); -vi.mock("@t3tools/client-runtime/environment", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("@t3tools/client-runtime/environment", () => ({ scopedProjectKey: () => "remote-project", scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); -vi.mock("@t3tools/contracts", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default", DEFAULT_SERVER_SETTINGS: {}, })); @@ -140,8 +138,7 @@ vi.mock("@tanstack/react-router", () => ({ useParams: () => null, useRouter: () => testState.router, })); -vi.mock("react", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("react", () => ({ useCallback: (callback: T) => callback, useMemo: (factory: () => T) => factory(), })); @@ -202,49 +199,6 @@ vi.mock("../uiStateStore", () => ({ vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); import { useNewThreadHandler } from "./useHandleNewThread"; -import { useComposerDraftStore } from "../composerDraftStore"; - -it("pins a reused automatic draft to the explicitly selected checkout", async () => { - const { useComposerDraftStore: realDraftStore, DraftId } = - await vi.importActual("../composerDraftStore"); - const { EnvironmentId, ProjectId, ThreadId } = - await vi.importActual("@t3tools/contracts"); - testState.reset(null); - const draftId = DraftId.make("draft-manual-checkout"); - const projectRef = { - environmentId: EnvironmentId.make("environment-ssh"), - projectId: ProjectId.make("project-remote"), - }; - const store = realDraftStore.getState(); - store.setLogicalProjectDraftThreadId( - "remote-project", - { - environmentId: EnvironmentId.make("environment-primary"), - projectId: ProjectId.make("local"), - }, - draftId, - { - threadId: ThreadId.make("thread-manual-checkout"), - environmentSelection: "auto", - loadBalancedEnvironmentId: EnvironmentId.make("environment-primary"), - }, - ); - const getState = vi.spyOn(useComposerDraftStore, "getState").mockReturnValue(store); - try { - const opened = await useNewThreadHandler()(projectRef, { environmentSelection: "manual" }); - - expect(opened?.draftId).toBe(draftId); - expect(store.getDraftSession(draftId)).toMatchObject({ - environmentId: projectRef.environmentId, - projectId: projectRef.projectId, - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); - } finally { - getState.mockRestore(); - realDraftStore.setState(store, true); - } -}); describe.each([ ["new", null], diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index b6143e453244c..cfb98fc83bd01 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -74,7 +74,6 @@ export function useNewThreadHandler() { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; - environmentSelection?: "manual"; }, ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); @@ -168,10 +167,6 @@ export function useNewThreadHandler() { const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const hasStartFromOriginOption = options?.startFromOrigin !== undefined; - const selectionOptions = - options?.environmentSelection === "manual" - ? { environmentSelection: "manual" as const, loadBalancedEnvironmentId: null } - : {}; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -272,7 +267,6 @@ export function useNewThreadHandler() { projectRef, emptyStoredDraftThread.draftId, { - ...selectionOptions, threadId: emptyStoredDraftThread.threadId, ...workspaceContext, ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), @@ -315,7 +309,6 @@ export function useNewThreadHandler() { setDraftThreadContext(currentRouteTarget.draftId, pickExplicitWorkspaceOptions(options)); } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { - ...selectionOptions, threadId: latestActiveDraftThread.threadId, createdAt: latestActiveDraftThread.createdAt, runtimeMode: latestActiveDraftThread.runtimeMode, @@ -347,7 +340,6 @@ export function useNewThreadHandler() { readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null ) { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, { - ...selectionOptions, threadId: racedDraft.threadId, createdAt: racedDraft.createdAt, runtimeMode: racedDraft.runtimeMode, @@ -362,7 +354,6 @@ export function useNewThreadHandler() { return { draftId: racedDraft.draftId, threadId: racedDraft.threadId }; } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { - ...selectionOptions, threadId, createdAt, branch: options?.branch ?? null, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index de21ef7a1e112..53c11954cec61 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -13,10 +13,7 @@ import { isPreviewAvailableFor } from "../browser/previewRuntime"; import { usePrimaryEnvironmentId } from "../state/environments"; import { useEnvironmentScope } from "../state/session"; import { selectProjectGroupingSettings } from "../logicalProject"; -import { - buildSidebarProjectSnapshots, - countSidebarProjectChoices, -} from "../sidebarProjectGrouping"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useScratchProject } from "../hooks/useScratchProject"; @@ -49,14 +46,12 @@ function ChatRouteGlobalShortcuts() { const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { scratchEnvironmentId, startScratchThread } = useScratchProject(); - const projectChoiceCount = countSidebarProjectChoices( - buildSidebarProjectSnapshots({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: () => null, - }), - ); + const projectGroupCount = buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }).length; const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -129,7 +124,7 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); - if (!legacySidebarEnabled && projectChoiceCount > 1) { + if (!legacySidebarEnabled && projectGroupCount > 1) { openCommandPalette({ open: "new-thread-in" }); return; } @@ -198,7 +193,7 @@ function ChatRouteGlobalShortcuts() { defaultProjectRef, previewOpen, primaryEnvironmentId, - projectChoiceCount, + projectGroupCount, routeThreadRef, scratchEnvironmentId, selectedThreadKeysSize, diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 230c2fa16a4bf..7182405bbd0f9 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -26,18 +26,6 @@ export interface SidebarProjectSnapshot extends Project { remoteEnvironmentLabels: readonly string[]; } -export function countSidebarProjectChoices(groups: ReadonlyArray): number { - let checkoutCount = 0; - let hasScratch = false; - for (const group of groups) { - for (const project of group.memberProjects) { - if (project.isScratch) hasScratch = true; - else checkoutCount += 1; - } - } - return checkoutCount + Number(hasScratch); -} - export function projectGroupsSpanEnvironments( groups: ReadonlyArray>, ): boolean { @@ -185,33 +173,8 @@ export function buildSidebarProjectSnapshots(input: { export function buildSidebarProjectPickerEntries(input: { groups: ReadonlyArray; preferredProjectRef: ScopedProjectRef | null; - expandCheckouts?: boolean; - isEnvironmentReachable?: (environmentId: EnvironmentId) => boolean; }) { const preferredProjectRef = input.preferredProjectRef; - if (input.expandCheckouts) { - // memberProjects already deduplicates physical checkouts. Keep distinct - // worktrees on the same environment as separate choices. - return input.groups - .flatMap((group) => - group.memberProjects.map((targetProject): SidebarProjectPickerEntry => ({ - group, - targetProject, - isPreferred: - preferredProjectRef !== null && - targetProject.environmentId === preferredProjectRef.environmentId && - targetProject.id === preferredProjectRef.projectId, - })), - ) - .toSorted((left, right) => { - const reachability = input.isEnvironmentReachable; - const reachableDelta = reachability - ? Number(reachability(right.targetProject.environmentId)) - - Number(reachability(left.targetProject.environmentId)) - : 0; - return reachableDelta || Number(right.isPreferred) - Number(left.isPreferred); - }); - } const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { const isPreferred = preferredProjectRef ? group.memberProjectRefs.some(