From cd6e4fc5ae84280e083ecb01c693abfead9dc5c8 Mon Sep 17 00:00:00 2001 From: Emt-lin <41323133+Emt-lin@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:29:41 +0800 Subject: [PATCH] feat: add recent usage sorting for chat history and project list. (#2077) * feat: add recent usage sorting for chat history and project list. # Conflicts: # src/main.ts * fix: use in-memory recency for chat history modal sorting. * refactor: move sort strategy selectors to AdvancedSettings. --- src/LLMProviders/projectManager.ts | 67 +++- src/commands/customCommandUtils.ts | 26 +- src/components/Chat.tsx | 1 + .../chat-components/ChatHistoryPopover.tsx | 34 +- .../chat-components/ProjectList.tsx | 87 ++++- .../modals/LoadChatHistoryModal.tsx | 44 ++- src/constants.ts | 2 + src/core/ChatPersistenceManager.test.ts | 125 +++++++ src/core/ChatPersistenceManager.ts | 49 ++- src/main.ts | 101 +++++- src/settings/model.ts | 19 + .../v2/components/AdvancedSettings.tsx | 45 +++ src/utils/chatHistoryUtils.ts | 36 ++ src/utils/recentUsageManager.test.ts | 274 ++++++++++++++ src/utils/recentUsageManager.ts | 336 ++++++++++++++++++ 15 files changed, 1187 insertions(+), 59 deletions(-) create mode 100644 src/utils/recentUsageManager.test.ts create mode 100644 src/utils/recentUsageManager.ts diff --git a/src/LLMProviders/projectManager.ts b/src/LLMProviders/projectManager.ts index f03f6782..6eed912c 100644 --- a/src/LLMProviders/projectManager.ts +++ b/src/LLMProviders/projectManager.ts @@ -16,10 +16,11 @@ import { logError, logInfo, logWarn } from "@/logger"; import CopilotPlugin from "@/main"; import { Mention } from "@/mentions/Mention"; import { getMatchingPatterns, shouldIndexFile } from "@/search/searchUtils"; -import { getSettings, subscribeToSettingsChange } from "@/settings/model"; +import { getSettings, subscribeToSettingsChange, updateSetting } from "@/settings/model"; import { FileParserManager } from "@/tools/FileParserManager"; import { err2String } from "@/utils"; import { isRateLimitError } from "@/utils/rateLimitUtils"; +import { RecentUsageManager } from "@/utils/recentUsageManager"; import { App, Notice, TFile } from "obsidian"; import { BrevilabsClient } from "./brevilabsClient"; import ChainManager from "./chainManager"; @@ -34,6 +35,7 @@ export default class ProjectManager { private readonly projectContextCache: ProjectContextCache; private fileParserManager: FileParserManager; private loadTracker: ProjectLoadTracker; + private readonly projectUsageTimestampsManager = new RecentUsageManager(); private constructor(app: App, plugin: CopilotPlugin) { this.app = app; @@ -90,8 +92,8 @@ export default class ProjectManager { for (const nextProject of nextProjects) { const prevProject = prevProjects.find((p) => p.id === nextProject.id); if (prevProject) { - // Check if project configuration has changed - if (JSON.stringify(prevProject) !== JSON.stringify(nextProject)) { + // Check if project configuration has changed (ignoring UsageTimestamps) + if (this.hasMeaningfulProjectConfigChange(prevProject, nextProject)) { // Compare project configuration changes and selectively update cache await this.compareAndUpdateCache(prevProject, nextProject); @@ -109,6 +111,19 @@ export default class ProjectManager { }); } + /** + * Determine whether a project configuration change should trigger cache reloads. + * Ignores `UsageTimestamps` updates used for "recently used" sorting. + */ + private hasMeaningfulProjectConfigChange( + prevProject: ProjectConfig, + nextProject: ProjectConfig + ): boolean { + const prevComparable = { ...prevProject, UsageTimestamps: 0 }; + const nextComparable = { ...nextProject, UsageTimestamps: 0 }; + return JSON.stringify(prevComparable) !== JSON.stringify(nextComparable); + } + public static getInstance(app: App, plugin: CopilotPlugin): ProjectManager { if (!ProjectManager.instance) { ProjectManager.instance = new ProjectManager(app, plugin); @@ -124,6 +139,49 @@ export default class ProjectManager { return this.currentProjectId; } + /** + * Touch the project's usage timestamp in settings with throttled persistence. + * Memory is always updated immediately (for UI sorting), but settings writes are throttled. + */ + private touchProjectUsageTimestamps(project: ProjectConfig): void { + // Always update memory for immediate UI feedback + this.projectUsageTimestampsManager.touch(project.id); + + // Check if we should persist to settings (throttled) + const currentProjects = getSettings().projectList || []; + const persistedProject = currentProjects.find((p) => p.id === project.id); + if (!persistedProject) { + return; + } + + const timestampToPersist = this.projectUsageTimestampsManager.shouldPersist( + project.id, + persistedProject.UsageTimestamps + ); + + if (timestampToPersist === null) { + return; + } + + updateSetting( + "projectList", + currentProjects.map((p) => + p.id === project.id ? { ...p, UsageTimestamps: timestampToPersist } : p + ) + ); + + // Mark persistence successful for throttling purposes + this.projectUsageTimestampsManager.markPersisted(project.id, timestampToPersist); + } + + /** + * Get the project usage timestamps manager for use in sorting. + * This allows UI components to use in-memory values for immediate feedback. + */ + public getProjectUsageTimestampsManager(): RecentUsageManager { + return this.projectUsageTimestampsManager; + } + public async switchProject(project: ProjectConfig | null): Promise { try { // Clear all project context loading states @@ -168,6 +226,9 @@ export default class ProjectManager { // fresh chat view this.refreshChatView(); + // Touch "recently used" timestamp only after a successful switch. + this.touchProjectUsageTimestamps(project); + logInfo(`Switched to project: ${project.name}`); } catch (error) { logError(`Failed to switch project: ${error}`); diff --git a/src/commands/customCommandUtils.ts b/src/commands/customCommandUtils.ts index 6e42e298..a62822bc 100644 --- a/src/commands/customCommandUtils.ts +++ b/src/commands/customCommandUtils.ts @@ -27,6 +27,7 @@ import { getNotesFromTags, processVariableNameForNotePath, } from "@/utils"; +import { sortByStrategy } from "@/utils/recentUsageManager"; import { NOTE_CONTEXT_PROMPT_TAG, SELECTED_TEXT_TAG, @@ -145,25 +146,28 @@ export async function loadAllCustomCommands(): Promise { } export function sortCommandsByOrder(commands: CustomCommand[]): CustomCommand[] { - return [...commands].sort((a, b) => { - if (a.order === b.order) { - return a.title.localeCompare(b.title); - } - return a.order - b.order; + return sortByStrategy(commands, "manual", { + getName: (command) => command.title, + getCreatedAtMs: () => 0, + getLastUsedAtMs: () => 0, + getManualOrder: (command) => command.order, }); } export function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[] { - return [...commands].sort((a, b) => { - if (a.lastUsedMs === b.lastUsedMs) { - return a.title.localeCompare(b.title); - } - return b.lastUsedMs - a.lastUsedMs; + return sortByStrategy(commands, "recent", { + getName: (command) => command.title, + getCreatedAtMs: () => 0, + getLastUsedAtMs: (command) => command.lastUsedMs, }); } export function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCommand[] { - return [...commands].sort((a, b) => a.title.localeCompare(b.title)); + return sortByStrategy(commands, "name", { + getName: (command) => command.title, + getCreatedAtMs: () => 0, + getLastUsedAtMs: () => 0, + }); } /** diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index 95e4e8e1..a6b033a7 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -847,6 +847,7 @@ const ChatInternal: React.FC(null); const isMobile = Platform.isMobile; + const settings = useSettingsValue(); const filteredHistory = useMemo(() => { if (!searchQuery.trim()) return chatHistory; @@ -46,7 +50,29 @@ export function ChatHistoryPopover({ ); }, [chatHistory, searchQuery]); + const sortedHistory = useMemo(() => { + return sortByStrategy(filteredHistory, settings.chatHistorySortStrategy, { + getName: (chat) => chat.title, + getCreatedAtMs: (chat) => chat.createdAt.getTime(), + getLastUsedAtMs: (chat) => chat.lastAccessedAt.getTime(), + }); + }, [filteredHistory, settings.chatHistorySortStrategy]); + const groupedHistory = useMemo(() => { + const sortStrategy = settings.chatHistorySortStrategy; + + // For name sorting, show a flat list without time-based grouping + if (sortStrategy === "name") { + return [ + { + key: "All", + label: "All", + chats: sortedHistory, + priority: 0, + }, + ]; + } + const groups: Array<{ key: string; label: string; @@ -56,8 +82,10 @@ export function ChatHistoryPopover({ const groupMap = new Map(); const now = new Date(); - filteredHistory.forEach((chat) => { - const diffTime = now.getTime() - chat.createdAt.getTime(); + sortedHistory.forEach((chat) => { + // Use lastAccessedAt for "recent" strategy, createdAt for "created" strategy + const referenceDate = sortStrategy === "recent" ? chat.lastAccessedAt : chat.createdAt; + const diffTime = now.getTime() - referenceDate.getTime(); const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); let groupKey: string; @@ -95,7 +123,7 @@ export function ChatHistoryPopover({ // Sort by priority, ensuring Today is at the top. return groups.sort((a, b) => a.priority - b.priority); - }, [filteredHistory]); + }, [settings.chatHistorySortStrategy, sortedHistory]); const handleStartEdit = (id: string, currentTitle: string) => { setEditingId(id); diff --git a/src/components/chat-components/ProjectList.tsx b/src/components/chat-components/ProjectList.tsx index 802c670e..43487fd1 100644 --- a/src/components/chat-components/ProjectList.tsx +++ b/src/components/chat-components/ProjectList.tsx @@ -15,7 +15,8 @@ import { HelpTooltip } from "@/components/ui/help-tooltip"; import { SearchBar } from "@/components/ui/SearchBar"; import { cn } from "@/lib/utils"; import { logError } from "@/logger"; -import { updateSetting } from "@/settings/model"; +import { updateSetting, useSettingsValue } from "@/settings/model"; +import { RecentUsageManager, sortByStrategy } from "@/utils/recentUsageManager"; import { ChevronDown, ChevronUp, @@ -32,6 +33,32 @@ import React, { memo, useEffect, useMemo, useState } from "react"; import { filterProjects } from "@/utils/projectUtils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +/** + * Subscribe to a {@link RecentUsageManager} revision so in-memory touches can trigger + * re-sorting even when the backing list reference stays unchanged (e.g. when persistence + * is throttled). + */ +function useRecentUsageManagerRevision( + manager: RecentUsageManager | null | undefined +): number { + const [revision, setRevision] = useState(() => manager?.getRevision() ?? 0); + + useEffect(() => { + if (!manager) { + setRevision(0); + return; + } + + setRevision(manager.getRevision()); + + return manager.subscribe(() => { + setRevision(manager.getRevision()); + }); + }, [manager]); + + return revision; +} + function ProjectItem({ project, loadContext, @@ -126,6 +153,7 @@ export const ProjectList = memo( projects, defaultOpen = false, app, + plugin, onProjectAdded, onEditProject, hasMessages = false, @@ -137,6 +165,7 @@ export const ProjectList = memo( projects: ProjectConfig[]; defaultOpen?: boolean; app: App; + plugin?: any; // CopilotPlugin, optional for backwards compatibility onProjectAdded: (project: ProjectConfig) => void; onEditProject: (originP: ProjectConfig, updateP: ProjectConfig) => void; hasMessages?: boolean; @@ -149,6 +178,14 @@ export const ProjectList = memo( const [selectedProject, setSelectedProject] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const chatInput = useChatInput(); + const settings = useSettingsValue(); + + // Get the project usage manager for subscription + const projectUsageTimestampsManager = + plugin?.projectManager?.getProjectUsageTimestampsManager?.() as + | RecentUsageManager + | undefined; + const projectUsageRevision = useRecentUsageManagerRevision(projectUsageTimestampsManager); // Auto collapse when messages appear useEffect(() => { @@ -157,10 +194,38 @@ export const ProjectList = memo( } }, [hasMessages]); + // Sort projects based on sort strategy + // Note: projectUsageRevision triggers re-sort when in-memory timestamps change, + // even though it's not directly referenced in the callback + const sortedProjects = useMemo( + () => + sortByStrategy(projects, settings.projectListSortStrategy, { + getName: (project) => project.name, + getCreatedAtMs: (project) => project.created, + getLastUsedAtMs: (project) => { + // Use effective last used time (prefers in-memory value for immediate UI updates) + if (projectUsageTimestampsManager) { + return projectUsageTimestampsManager.getEffectiveLastUsedAt( + project.id, + project.UsageTimestamps + ); + } + return project.UsageTimestamps; + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps -- projectUsageRevision triggers re-sort when manager's in-memory state changes + [ + projects, + settings.projectListSortStrategy, + projectUsageTimestampsManager, + projectUsageRevision, + ] + ); + // Filter projects based on search query const filteredProjects = useMemo(() => { - return filterProjects(projects, searchQuery); - }, [projects, searchQuery]); + return filterProjects(sortedProjects, searchQuery); + }, [sortedProjects, searchQuery]); const handleAddProject = () => { const modal = new AddProjectModal(app, async (project: ProjectConfig) => { @@ -239,7 +304,7 @@ export const ProjectList = memo(