diff --git a/packages/ui/src/features/folder-picker/GitHubRepoPicker.tsx b/packages/ui/src/features/folder-picker/GitHubRepoPicker.tsx index 65e477ba2..d42f1c1ff 100644 --- a/packages/ui/src/features/folder-picker/GitHubRepoPicker.tsx +++ b/packages/ui/src/features/folder-picker/GitHubRepoPicker.tsx @@ -1,4 +1,8 @@ -import { ArrowClockwise, GithubLogo } from "@phosphor-icons/react"; +import { + ArrowClockwise, + ClockCounterClockwise, + GithubLogo, +} from "@phosphor-icons/react"; import { Button, Combobox, @@ -12,7 +16,14 @@ import { } from "@posthog/quill"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { defaultFilter } from "cmdk"; -import { type RefObject, useEffect, useMemo, useRef, useState } from "react"; +import { + Fragment, + type RefObject, + useEffect, + useMemo, + useRef, + useState, +} from "react"; const COMBOBOX_INITIAL_LIMIT = 50; @@ -20,6 +31,11 @@ interface GitHubRepoPickerProps { value: string | null; onChange: (repo: string | null) => void; repositories: string[]; + /** + * Recently-used repositories to pin above the full list, most-recent first. + * Only shown while there is no active search query. + */ + recentRepositories?: string[]; isLoading: boolean; placeholder?: string; size?: "1" | "2"; @@ -41,6 +57,7 @@ export function GitHubRepoPicker({ value, onChange, repositories, + recentRepositories, isLoading, placeholder = "Select repository...", disabled = false, @@ -70,6 +87,35 @@ export function GitHubRepoPicker({ const onlyRepo = !remoteMode && repositories.length === 1 ? repositories[0] : null; const trimmedSearchQuery = searchQuery.trim(); + // Pin recently-used repos to the top, but only while idle: once the user + // starts searching they want matches, not their history. + const pinnedRecentRepositories = useMemo(() => { + if (trimmedSearchQuery || !recentRepositories?.length) { + return [] as string[]; + } + const seen = new Set(); + const result: string[] = []; + for (const repo of recentRepositories) { + if (!seen.has(repo)) { + seen.add(repo); + result.push(repo); + } + } + return result; + }, [recentRepositories, trimmedSearchQuery]); + const pinnedRecentSet = useMemo( + () => new Set(pinnedRecentRepositories), + [pinnedRecentRepositories], + ); + const displayRepositories = useMemo(() => { + if (pinnedRecentRepositories.length === 0) { + return repositories; + } + return [ + ...pinnedRecentRepositories, + ...repositories.filter((repo) => !pinnedRecentSet.has(repo)), + ]; + }, [pinnedRecentRepositories, pinnedRecentSet, repositories]); const filteredRepositoryCount = useMemo(() => { if (!trimmedSearchQuery) { return repositories.length; @@ -136,7 +182,7 @@ export function GitHubRepoPicker({ return ( { @@ -215,11 +261,37 @@ export function GitHubRepoPicker({ : "No repositories found."} - {(repo: string) => ( - - {repo} - - )} + {(repo: string) => { + const isPinned = pinnedRecentSet.has(repo); + const isFirstPinned = + isPinned && repo === pinnedRecentRepositories[0]; + const isLastPinned = + isPinned && + repo === + pinnedRecentRepositories[pinnedRecentRepositories.length - 1]; + return ( + + {isFirstPinned ? ( +
+ Recent +
+ ) : null} + + {isPinned ? ( + + ) : null} + {repo} + + {isLastPinned ? ( +
+ ) : null} + + ); + }} {(hasMore || diff --git a/packages/ui/src/features/settings/settingsStore.test.ts b/packages/ui/src/features/settings/settingsStore.test.ts index 7afa8cddc..91844facf 100644 --- a/packages/ui/src/features/settings/settingsStore.test.ts +++ b/packages/ui/src/features/settings/settingsStore.test.ts @@ -1,6 +1,9 @@ import { registerRendererStateStorage } from "@posthog/ui/shell/rendererStorage"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useSettingsStore } from "./settingsStore"; +import { + RECENT_CLOUD_REPOSITORIES_LIMIT, + useSettingsStore, +} from "./settingsStore"; const getItem = vi.fn(); const setItem = vi.fn(); @@ -20,6 +23,7 @@ describe("feature settingsStore cloud selections", () => { useSettingsStore.setState({ allowBypassPermissions: false, lastUsedCloudRepository: null, + recentCloudRepositories: [], }); }); @@ -57,6 +61,45 @@ describe("feature settingsStore cloud selections", () => { ); }); + it("tracks recently used cloud repositories most-recent first", () => { + const { addRecentCloudRepository } = useSettingsStore.getState(); + + addRecentCloudRepository("posthog/posthog"); + addRecentCloudRepository("posthog/posthog-js"); + addRecentCloudRepository("PostHog/Code"); + + expect(useSettingsStore.getState().recentCloudRepositories).toEqual([ + "posthog/code", + "posthog/posthog-js", + "posthog/posthog", + ]); + }); + + it("dedupes and promotes a re-selected repository to the front", () => { + const { addRecentCloudRepository } = useSettingsStore.getState(); + + addRecentCloudRepository("posthog/posthog"); + addRecentCloudRepository("posthog/posthog-js"); + addRecentCloudRepository("posthog/posthog"); + + expect(useSettingsStore.getState().recentCloudRepositories).toEqual([ + "posthog/posthog", + "posthog/posthog-js", + ]); + }); + + it("caps the recent repositories list", () => { + const { addRecentCloudRepository } = useSettingsStore.getState(); + + for (let i = 0; i < RECENT_CLOUD_REPOSITORIES_LIMIT + 3; i++) { + addRecentCloudRepository(`posthog/repo-${i}`); + } + + expect(useSettingsStore.getState().recentCloudRepositories).toHaveLength( + RECENT_CLOUD_REPOSITORIES_LIMIT, + ); + }); + it("rehydrates the unsafe mode toggle", async () => { getItem.mockResolvedValue( JSON.stringify({ diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index 4e2e496e5..eb6ca3512 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -48,6 +48,9 @@ export interface HintState { learned: boolean; } +/** How many recently-used cloud repositories we remember for the picker. */ +export const RECENT_CLOUD_REPOSITORIES_LIMIT = 5; + // ---------- Store shape ---------- interface SettingsStore { @@ -60,6 +63,7 @@ interface SettingsStore { lastUsedModel: string | null; lastUsedReasoningEffort: string | null; lastUsedCloudRepository: string | null; + recentCloudRepositories: string[]; lastUsedEnvironments: Record; defaultInitialTaskMode: DefaultInitialTaskMode; lastUsedInitialTaskMode: ExecutionMode; @@ -72,6 +76,7 @@ interface SettingsStore { setLastUsedModel: (model: string) => void; setLastUsedReasoningEffort: (effort: string) => void; setLastUsedCloudRepository: (repo: string | null) => void; + addRecentCloudRepository: (repo: string) => void; setLastUsedEnvironment: ( repoPath: string, environmentId: string | null, @@ -149,6 +154,7 @@ export const useSettingsStore = create()( lastUsedModel: null, lastUsedReasoningEffort: null, lastUsedCloudRepository: null, + recentCloudRepositories: [], lastUsedEnvironments: {}, defaultInitialTaskMode: "plan", lastUsedInitialTaskMode: "plan", @@ -164,6 +170,15 @@ export const useSettingsStore = create()( set({ lastUsedReasoningEffort: effort }), setLastUsedCloudRepository: (repo) => set({ lastUsedCloudRepository: repo }), + addRecentCloudRepository: (repo) => + set((state) => { + const normalized = repo.toLowerCase(); + const next = [ + normalized, + ...state.recentCloudRepositories.filter((r) => r !== normalized), + ].slice(0, RECENT_CLOUD_REPOSITORIES_LIMIT); + return { recentCloudRepositories: next }; + }), setLastUsedEnvironment: (repoPath, environmentId) => set((state) => { const next = { ...state.lastUsedEnvironments }; @@ -279,6 +294,7 @@ export const useSettingsStore = create()( lastUsedModel: state.lastUsedModel, lastUsedReasoningEffort: state.lastUsedReasoningEffort, lastUsedCloudRepository: state.lastUsedCloudRepository, + recentCloudRepositories: state.recentCloudRepositories, lastUsedEnvironments: state.lastUsedEnvironments, defaultInitialTaskMode: state.defaultInitialTaskMode, lastUsedInitialTaskMode: state.lastUsedInitialTaskMode, diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index b228986a1..584562620 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -58,6 +58,9 @@ import { CloudGithubMissingNotice } from "./CloudGithubMissingNotice"; import { SuggestedTasksPanel } from "./SuggestedTasksPanel"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; +/** How many recently-used repos to pin above the full cloud repo list. */ +const RECENT_CLOUD_REPOSITORIES_DISPLAY_COUNT = 3; + interface TaskInputProps { sessionId?: string; onTaskCreated?: (task: Task) => void; @@ -110,6 +113,8 @@ export function TaskInput({ setLastUsedAdapter, lastUsedCloudRepository, setLastUsedCloudRepository, + recentCloudRepositories, + addRecentCloudRepository, allowBypassPermissions, setLastUsedEnvironment, getLastUsedEnvironment, @@ -207,6 +212,16 @@ export function TaskInput({ hasGithubIntegration, } = useUserRepositoryIntegration(); + // Surface the few most recently-used repos at the top of the picker, keeping + // only those still connected so we never pin a repo the user has lost access to. + const pinnedRecentRepositories = useMemo(() => { + if (repositories.length === 0) return []; + const connected = new Set(repositories); + return recentCloudRepositories + .filter((repo) => connected.has(repo)) + .slice(0, RECENT_CLOUD_REPOSITORIES_DISPLAY_COUNT); + }, [recentCloudRepositories, repositories]); + const [workspaceMode, setWorkspaceModeState] = useState(() => { if (initialCloudRepository) return "cloud"; return lastUsedWorkspaceMode || "local"; @@ -320,8 +335,9 @@ export function TaskInput({ const normalizedRepo = repo.toLowerCase(); setSelectedRepository(normalizedRepo); setLastUsedCloudRepository(normalizedRepo); + addRecentCloudRepository(normalizedRepo); }, - [setLastUsedCloudRepository], + [setLastUsedCloudRepository, addRecentCloudRepository], ); useEffect(() => { @@ -697,6 +713,7 @@ export function TaskInput({ ? visibleCloudRepositories : repositories } + recentRepositories={pinnedRecentRepositories} isLoading={ isLoadingRepos || (isCloudRepoPickerOpen && cloudRepositoriesLoading)