From 529498372ed17c657c771ba14e42f97673d48a94 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Fri, 12 Jun 2026 10:44:06 +0100 Subject: [PATCH] feat(task-input): pin recently-used repos atop the cloud repo picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a cloud repository meant scrolling or typing past a long GitHub repo list every time, even though most users reuse the same few repos. Track the most recently selected cloud repositories (MRU, capped at 5) in settings and surface the top 3 still-connected ones in a "Recent" section pinned above the full list in the new-task repo picker. The pinned section only shows while idle — once the user starts searching, normal results take over. Generated-By: PostHog Code Task-Id: 29622575-d4f9-43c6-acd7-d20cc8ee2e98 --- .../folder-picker/GitHubRepoPicker.tsx | 88 +++++++++++++++++-- .../features/settings/settingsStore.test.ts | 45 +++++++++- .../ui/src/features/settings/settingsStore.ts | 16 ++++ .../task-detail/components/TaskInput.tsx | 19 +++- 4 files changed, 158 insertions(+), 10 deletions(-) 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)