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.
This commit is contained in:
Emt-lin 2026-01-13 11:29:41 +08:00 committed by GitHub
parent 270663aa1b
commit cd6e4fc5ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1187 additions and 59 deletions

View file

@ -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<string>();
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<string> {
return this.projectUsageTimestampsManager;
}
public async switchProject(project: ProjectConfig | null): Promise<void> {
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}`);

View file

@ -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<CustomCommand[]> {
}
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,
});
}
/**

View file

@ -847,6 +847,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
projects={settings.projectList || []}
defaultOpen={true}
app={app}
plugin={plugin}
hasMessages={false}
onProjectAdded={handleAddProject}
onEditProject={handleEditProject}

View file

@ -7,12 +7,15 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { logError } from "@/logger";
import { useSettingsValue } from "@/settings/model";
import { sortByStrategy } from "@/utils/recentUsageManager";
import { Platform } from "obsidian";
export interface ChatHistoryItem {
id: string;
title: string;
createdAt: Date;
lastAccessedAt: Date;
}
interface ChatHistoryPopoverProps {
@ -38,6 +41,7 @@ export function ChatHistoryPopover({
const [open, setOpen] = useState(false);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(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<string, ChatHistoryItem[]>();
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);

View file

@ -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<Key extends string>(
manager: RecentUsageManager<Key> | 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<ProjectConfig | null>(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<string>
| 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(
<Select
value={selectedProject.name}
onValueChange={(value) => {
const project = projects.find((p) => p.name === value);
const project = sortedProjects.find((p) => p.name === value);
if (project) {
handleLoadContext(project);
}
@ -254,7 +319,7 @@ export const ProjectList = memo(
</SelectValue>
</SelectTrigger>
<SelectContent className="tw-truncate">
{projects.map((project) => (
{sortedProjects.map((project) => (
<SelectItem
key={project.name}
value={project.name}
@ -352,13 +417,11 @@ export const ProjectList = memo(
{/* Search input box */}
{projects.length > 0 && (
<div className="tw-px-4 tw-pb-2 tw-pt-3">
<div className="tw-relative">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search projects..."
/>
</div>
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search projects..."
/>
</div>
)}
<div className="tw-max-h-[calc(3*5.7rem)] tw-overflow-y-auto tw-px-4 tw-pb-6 tw-pt-3">

View file

@ -1,37 +1,57 @@
import { getChatDisplayText } from "@/utils/chatHistoryUtils";
import {
extractChatDate,
extractChatLastAccessedAtMs,
extractChatTitle,
getChatDisplayText,
} from "@/utils/chatHistoryUtils";
import { getSettings } from "@/settings/model";
import { RecentUsageManager, sortByStrategy } from "@/utils/recentUsageManager";
import { App, FuzzySuggestModal, TFile } from "obsidian";
export class LoadChatHistoryModal extends FuzzySuggestModal<TFile> {
private onChooseFile: (file: TFile) => void;
/**
* Create a modal for selecting a chat history file from the vault.
*/
constructor(
app: App,
private chatFiles: TFile[],
private chatHistoryLastAccessedAtManager: RecentUsageManager<string>,
onChooseFile: (file: TFile) => void
) {
super(app);
this.onChooseFile = onChooseFile;
}
/**
* Return chat history files sorted by the configured strategy.
* Uses in-memory recency data for immediate UI feedback when available.
*/
getItems(): TFile[] {
return this.chatFiles.sort((a, b) => {
const getEpoch = (file: TFile) => {
const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
return frontmatter && frontmatter.epoch ? frontmatter.epoch : file.stat.ctime;
};
const epochA = getEpoch(a);
const epochB = getEpoch(b);
// Sort in descending order (most recent first)
return epochB - epochA;
const sortStrategy = getSettings().chatHistorySortStrategy;
return sortByStrategy(this.chatFiles, sortStrategy, {
getName: (file) => extractChatTitle(file),
getCreatedAtMs: (file) => extractChatDate(file).getTime(),
getLastUsedAtMs: (file) => {
// Reason: Use getEffectiveLastUsedAt to prefer in-memory value over persisted frontmatter.
// This ensures the modal reflects recent access immediately, even within the throttle window.
const persistedMs = extractChatLastAccessedAtMs(file);
return this.chatHistoryLastAccessedAtManager.getEffectiveLastUsedAt(file.path, persistedMs);
},
});
}
/**
* Render the display label for a chat history file.
*/
getItemText(file: TFile): string {
return getChatDisplayText(file);
}
/**
* Handle user selection.
*/
onChooseItem(file: TFile, _evt: MouseEvent | KeyboardEvent) {
this.onChooseFile(file);
}

View file

@ -820,6 +820,8 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
lexicalSearchRamLimit: 100, // Default 100 MB
promptUsageTimestamps: {},
promptSortStrategy: PromptSortStrategy.TIMESTAMP,
chatHistorySortStrategy: "recent",
projectListSortStrategy: "recent",
defaultConversationNoteName: "{$topic}@{$date}_{$time}",
/** @deprecated */
inlineEditCommands: [],

View file

@ -1465,4 +1465,129 @@ tags:
expect(modelKeyLine).toBe('modelKey: "model\\\\with\\\\backslash|provider"');
});
});
describe("lastAccessedAt preservation", () => {
it("should preserve lastAccessedAt when updating existing file", async () => {
const messages: ChatMessage[] = [
{
id: "1",
message: "Hello",
sender: USER_SENDER,
timestamp: {
epoch: 1695513480000,
display: "2024/09/23 22:18:00",
fileName: "2024_09_23_221800",
},
isVisible: true,
},
];
const existingFile = {
path: "test-folder/Hello@20240923_221800.md",
} as unknown as TFile;
const getFilesSpy = jest
.spyOn(persistenceManager, "getChatHistoryFiles")
.mockResolvedValue([existingFile]);
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
mockApp.metadataCache.getFileCache.mockReturnValue({
frontmatter: {
epoch: 1695513480000,
topic: "Existing Topic",
lastAccessedAt: 1700000000000, // Existing lastAccessedAt
},
});
await persistenceManager.saveChat("gpt-4");
// Verify that modify was called with content containing lastAccessedAt
expect(mockApp.vault.modify).toHaveBeenCalledWith(
existingFile,
expect.stringContaining("lastAccessedAt: 1700000000000")
);
getFilesSpy.mockRestore();
});
it("should not include lastAccessedAt when it does not exist", async () => {
const messages: ChatMessage[] = [
{
id: "1",
message: "New chat",
sender: USER_SENDER,
timestamp: {
epoch: 1695513480000,
display: "2024/09/23 22:18:00",
fileName: "2024_09_23_221800",
},
isVisible: true,
},
];
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
mockApp.vault.getAbstractFileByPath.mockReturnValue(true);
mockApp.vault.create.mockResolvedValue({
path: "test-folder/New_chat@20240923_221800.md",
} as TFile);
await persistenceManager.saveChat("gpt-4");
// Verify that create was called with content NOT containing lastAccessedAt
const createCall = mockApp.vault.create.mock.calls[0];
const content = createCall[1] as string;
expect(content).not.toContain("lastAccessedAt:");
});
it("should preserve lastAccessedAt during conflict resolution", async () => {
const messages: ChatMessage[] = [
{
id: "1",
message: "Conflict test",
sender: USER_SENDER,
timestamp: {
epoch: 1695513480000,
display: "2024/09/23 22:18:00",
fileName: "2024_09_23_221800",
},
isVisible: true,
},
];
const existingFile = Object.create(TFile.prototype);
Object.assign(existingFile, {
path: "test-folder/Conflict_test@20240923_221800.md",
basename: "Conflict_test@20240923_221800",
});
const getFilesSpy = jest
.spyOn(persistenceManager, "getChatHistoryFiles")
.mockResolvedValue([]);
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
mockApp.vault.create.mockRejectedValue(new Error("File already exists"));
mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === "test-folder/Conflict_test@20240923_221800.md") {
return existingFile;
}
return null;
});
mockApp.metadataCache.getFileCache.mockReturnValue({
frontmatter: {
topic: "Conflict Topic",
lastAccessedAt: 1700000000000,
},
});
await persistenceManager.saveChat("gpt-4");
// Verify that modify was called with content containing lastAccessedAt
expect(mockApp.vault.modify).toHaveBeenCalledWith(
existingFile,
expect.stringContaining("lastAccessedAt: 1700000000000")
);
getFilesSpy.mockRestore();
});
});
});

View file

@ -60,9 +60,11 @@ export class ChatPersistenceManager {
// Check if a file with this epoch already exists
const existingFile = await this.findFileByEpoch(firstMessageEpoch);
let existingTopic = existingFile
? this.app.metadataCache.getFileCache(existingFile)?.frontmatter?.topic
const existingFrontmatter = existingFile
? this.app.metadataCache.getFileCache(existingFile)?.frontmatter
: undefined;
let existingTopic = existingFrontmatter?.topic;
const existingLastAccessedAt = existingFrontmatter?.lastAccessedAt;
const preferredFileName = existingFile
? existingFile.path
@ -72,7 +74,8 @@ export class ChatPersistenceManager {
chatContent,
firstMessageEpoch,
modelKey,
existingTopic
existingTopic,
existingLastAccessedAt
);
let targetFile: TFile | null = existingFile;
@ -90,11 +93,21 @@ export class ChatPersistenceManager {
if (this.isFileAlreadyExistsError(error)) {
const conflictFile = this.app.vault.getAbstractFileByPath(preferredFileName);
if (conflictFile && conflictFile instanceof TFile) {
// Update existingTopic to prevent unnecessary regeneration
existingTopic =
this.app.metadataCache.getFileCache(conflictFile)?.frontmatter?.topic ??
existingTopic;
await this.app.vault.modify(conflictFile, noteContent);
// Read existing frontmatter to preserve lastAccessedAt and topic
const conflictFrontmatter =
this.app.metadataCache.getFileCache(conflictFile)?.frontmatter;
existingTopic = conflictFrontmatter?.topic ?? existingTopic;
const conflictLastAccessedAt = conflictFrontmatter?.lastAccessedAt;
// Regenerate content with preserved frontmatter values
const updatedContent = this.generateNoteContent(
chatContent,
firstMessageEpoch,
modelKey,
existingTopic,
conflictLastAccessedAt
);
await this.app.vault.modify(conflictFile, updatedContent);
targetFile = conflictFile;
new Notice("Existing chat note found - updating it now.");
logInfo(
@ -119,7 +132,21 @@ export class ChatPersistenceManager {
if (this.isFileAlreadyExistsError(fallbackError)) {
const conflictFile = this.app.vault.getAbstractFileByPath(fallbackName);
if (conflictFile && conflictFile instanceof TFile) {
await this.app.vault.modify(conflictFile, noteContent);
// Read existing frontmatter to preserve lastAccessedAt
const conflictFrontmatter =
this.app.metadataCache.getFileCache(conflictFile)?.frontmatter;
const conflictLastAccessedAt = conflictFrontmatter?.lastAccessedAt;
const conflictTopic = conflictFrontmatter?.topic;
// Regenerate content with preserved frontmatter values
const updatedContent = this.generateNoteContent(
chatContent,
firstMessageEpoch,
modelKey,
conflictTopic,
conflictLastAccessedAt
);
await this.app.vault.modify(conflictFile, updatedContent);
targetFile = conflictFile;
new Notice("Existing chat note found - updating it now.");
logInfo(
@ -600,7 +627,8 @@ ${conversationSummary}`;
chatContent: string,
firstMessageEpoch: number,
modelKey: string,
topic?: string
topic?: string,
lastAccessedAt?: number
): string {
const settings = getSettings();
const currentProject = getCurrentProject();
@ -609,6 +637,7 @@ ${conversationSummary}`;
epoch: ${firstMessageEpoch}
modelKey: "${escapeYamlString(modelKey)}"
${topic ? `topic: "${topic}"` : ""}
${lastAccessedAt ? `lastAccessedAt: ${lastAccessedAt}` : ""}
${currentProject ? `projectId: ${currentProject.id}` : ""}
${currentProject ? `projectName: ${currentProject.name}` : ""}
tags:

View file

@ -19,7 +19,7 @@ import { ABORT_REASON, CHAT_VIEWTYPE, DEFAULT_OPEN_AREA, EVENT_NAMES } from "@/c
import { ChatManager } from "@/core/ChatManager";
import { MessageRepository } from "@/core/MessageRepository";
import { encryptAllKeys } from "@/encryptionService";
import { logInfo } from "@/logger";
import { logInfo, logWarn } from "@/logger";
import { logFileManager } from "@/logFileManager";
import { UserMemoryManager } from "@/memory/UserMemoryManager";
import { clearRecordedPromptPayload } from "@/LLMProviders/chainRunner/utils/promptPayloadRecorder";
@ -54,7 +54,12 @@ import {
WorkspaceLeaf,
} from "obsidian";
import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
import { extractChatTitle, extractChatDate } from "@/utils/chatHistoryUtils";
import {
extractChatDate,
extractChatLastAccessedAtMs,
extractChatTitle,
} from "@/utils/chatHistoryUtils";
import { RecentUsageManager } from "@/utils/recentUsageManager";
import { v4 as uuidv4 } from "uuid";
// Removed unused FileTrackingState interface
@ -73,6 +78,7 @@ export default class CopilotPlugin extends Plugin {
private selectionDebounceTimer?: number;
private selectionChangeHandler?: () => void;
private webSelectionTracker?: WebSelectionTracker;
private readonly chatHistoryLastAccessedAtManager = new RecentUsageManager<string>();
async onload(): Promise<void> {
await this.loadSettings();
@ -550,7 +556,12 @@ export default class CopilotPlugin extends Plugin {
new Notice("No chat history found.");
return;
}
new LoadChatHistoryModal(this.app, chatFiles, this.loadChatHistory.bind(this)).open();
new LoadChatHistoryModal(
this.app,
chatFiles,
this.chatHistoryLastAccessedAtManager,
this.loadChatHistory.bind(this)
).open();
}
async getChatHistoryFiles(): Promise<TFile[]> {
@ -582,11 +593,82 @@ export default class CopilotPlugin extends Plugin {
async getChatHistoryItems(): Promise<ChatHistoryItem[]> {
const files = await this.getChatHistoryFiles();
return files.map((file) => ({
id: file.path,
title: extractChatTitle(file),
createdAt: extractChatDate(file),
}));
return files.map((file) => {
const createdAt = extractChatDate(file);
const persistedLastAccessedAtMs = extractChatLastAccessedAtMs(file);
// Use effective last used time (prefers in-memory value for immediate UI updates)
const effectiveLastAccessedAtMs = this.chatHistoryLastAccessedAtManager.getEffectiveLastUsedAt(
file.path,
persistedLastAccessedAtMs ?? createdAt.getTime()
);
const lastAccessedAt = new Date(effectiveLastAccessedAtMs);
return {
id: file.path,
title: extractChatTitle(file),
createdAt,
lastAccessedAt,
};
});
}
/**
* Record that a chat history file was accessed by updating its `lastAccessedAt`
* YAML frontmatter field (epoch ms), with in-memory tracking and throttled persistence.
*
* Memory is always updated immediately (for UI sorting), but disk writes are throttled and monotonic.
*/
private async touchChatHistoryLastAccessedAt(file: TFile): Promise<void> {
try {
// Always update memory for immediate UI feedback
this.chatHistoryLastAccessedAtManager.touch(file.path);
// Check if we should persist to disk (throttled)
const persistedLastAccessedAtMs = extractChatLastAccessedAtMs(file);
const timestampToPersist = this.chatHistoryLastAccessedAtManager.shouldPersist(
file.path,
persistedLastAccessedAtMs
);
if (timestampToPersist === null) {
return;
}
if (!this.app.fileManager?.processFrontMatter) {
return;
}
let persistedAtMs = timestampToPersist;
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
// Monotonic protection: ensure we never write an older timestamp
const existingValue = Number(frontmatter.lastAccessedAt);
const existingAtMs =
Number.isFinite(existingValue) && existingValue > 0 ? existingValue : 0;
persistedAtMs = Math.max(existingAtMs, timestampToPersist);
if (existingAtMs === persistedAtMs) {
return;
}
frontmatter.lastAccessedAt = persistedAtMs;
});
// Mark persistence successful for throttling purposes
this.chatHistoryLastAccessedAtManager.markPersisted(file.path, persistedAtMs);
} catch (error) {
logWarn(`[CopilotPlugin] Failed to update chat lastAccessedAt for ${file.path}`, error);
}
}
/**
* Get the chat history last accessed at manager for use in sorting.
* This allows UI components to use in-memory values for immediate feedback.
*/
getChatHistoryLastAccessedAtManager(): RecentUsageManager<string> {
return this.chatHistoryLastAccessedAtManager;
}
async loadChatHistory(file: TFile) {
@ -603,6 +685,9 @@ export default class CopilotPlugin extends Plugin {
// Load messages using ChatUIState (which now uses ChatPersistenceManager internally)
await this.chatUIState.loadChatHistory(file);
// Touch "lastAccessedAt" timestamp (throttled to avoid frequent writes)
void this.touchChatHistoryLastAccessedAt(file);
// Update the view
const copilotView = (existingView || this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE)[0])
?.view as CopilotView;

View file

@ -4,6 +4,7 @@ import { v4 as uuidv4 } from "uuid";
import { UserMemoryManager } from "@/memory/UserMemoryManager";
import { type ChainType } from "@/chainFactory";
import { type SortStrategy, isSortStrategy } from "@/utils/recentUsageManager";
import {
BUILTIN_CHAT_MODELS,
BUILTIN_EMBEDDING_MODELS,
@ -104,6 +105,8 @@ export interface CopilotSettings {
activeEmbeddingModels: Array<CustomModel>;
promptUsageTimestamps: Record<string, number>;
promptSortStrategy: string;
chatHistorySortStrategy: SortStrategy;
projectListSortStrategy: SortStrategy;
embeddingRequestsPerMin: number;
embeddingBatchSize: number;
defaultOpenArea: DEFAULT_OPEN_AREA;
@ -434,6 +437,22 @@ export function sanitizeSettings(settings: CopilotSettings): CopilotSettings {
sanitizedSettings.customPromptsFolder =
promptsFolder.length > 0 ? promptsFolder : DEFAULT_SETTINGS.customPromptsFolder;
// Ensure chatHistorySortStrategy has a valid value (exclude "manual" which is only for custom commands)
if (
!isSortStrategy(sanitizedSettings.chatHistorySortStrategy) ||
sanitizedSettings.chatHistorySortStrategy === "manual"
) {
sanitizedSettings.chatHistorySortStrategy = DEFAULT_SETTINGS.chatHistorySortStrategy;
}
// Ensure projectListSortStrategy has a valid value (exclude "manual" which is only for custom commands)
if (
!isSortStrategy(sanitizedSettings.projectListSortStrategy) ||
sanitizedSettings.projectListSortStrategy === "manual"
) {
sanitizedSettings.projectListSortStrategy = DEFAULT_SETTINGS.projectListSortStrategy;
}
sanitizedSettings.qaExclusions = sanitizeQaExclusions(settingsToSanitize.qaExclusions);
return sanitizedSettings;

View file

@ -3,6 +3,7 @@ import { SettingItem } from "@/components/ui/setting-item";
import { logFileManager } from "@/logFileManager";
import { flushRecordedPromptPayloadToLog } from "@/LLMProviders/chainRunner/utils/promptPayloadRecorder";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { isSortStrategy } from "@/utils/recentUsageManager";
import React from "react";
export const AdvancedSettings: React.FC = () => {
@ -10,6 +11,50 @@ export const AdvancedSettings: React.FC = () => {
return (
<div className="tw-space-y-4">
{/* Sorting Settings Section */}
<section>
<div className="tw-mb-4 tw-flex tw-flex-col tw-gap-2">
<div className="tw-text-xl tw-font-bold">Sorting</div>
<div className="tw-text-sm tw-text-muted">
Configure default sort order for various lists.
</div>
</div>
<SettingItem
type="select"
title="Chat History Sort Strategy"
description="Sort order for the chat history list"
value={settings.chatHistorySortStrategy}
onChange={(value) => {
if (isSortStrategy(value)) {
updateSetting("chatHistorySortStrategy", value);
}
}}
options={[
{ label: "Recency", value: "recent" },
{ label: "Created", value: "created" },
{ label: "Alphabetical", value: "name" },
]}
/>
<SettingItem
type="select"
title="Project List Sort Strategy"
description="Sort order for the project list"
value={settings.projectListSortStrategy}
onChange={(value) => {
if (isSortStrategy(value)) {
updateSetting("projectListSortStrategy", value);
}
}}
options={[
{ label: "Recency", value: "recent" },
{ label: "Created", value: "created" },
{ label: "Alphabetical", value: "name" },
]}
/>
</section>
{/* Privacy Settings Section */}
<section>
<SettingItem

View file

@ -44,6 +44,42 @@ export function extractChatDate(file: TFile): Date {
}
}
/**
* Extract chat last accessed time (epoch ms) from a file.
* Uses frontmatter.lastAccessedAt if available, returns null otherwise.
*/
export function extractChatLastAccessedAtMs(file: TFile): number | null {
const frontmatter = app.metadataCache.getFileCache(file)?.frontmatter;
const rawValue = frontmatter?.lastAccessedAt;
if (typeof rawValue === "number" && Number.isFinite(rawValue) && rawValue > 0) {
return rawValue;
}
if (typeof rawValue === "string") {
const numeric = Number(rawValue);
if (Number.isFinite(numeric) && numeric > 0) {
return numeric;
}
const parsedDate = Date.parse(rawValue);
if (Number.isFinite(parsedDate)) {
return parsedDate;
}
}
return null;
}
/**
* Extract chat last accessed date from a file.
* Uses extractChatLastAccessedAtMs and returns a Date when available, null otherwise.
*/
export function extractChatLastAccessedAt(file: TFile): Date | null {
const lastAccessedAtMs = extractChatLastAccessedAtMs(file);
return lastAccessedAtMs ? new Date(lastAccessedAtMs) : null;
}
/**
* Get formatted display text for a chat file (title + formatted date).
* Used in chat history modals and similar UI components.

View file

@ -0,0 +1,274 @@
import { isSortStrategy, RecentUsageManager, sortByStrategy } from "./recentUsageManager";
describe("recentUsageManager", () => {
describe("isSortStrategy", () => {
it("returns true for valid strategies", () => {
expect(isSortStrategy("recent")).toBe(true);
expect(isSortStrategy("created")).toBe(true);
expect(isSortStrategy("name")).toBe(true);
expect(isSortStrategy("manual")).toBe(true);
});
it("returns false for invalid strategies", () => {
expect(isSortStrategy("invalid")).toBe(false);
expect(isSortStrategy("")).toBe(false);
expect(isSortStrategy(null)).toBe(false);
expect(isSortStrategy(undefined)).toBe(false);
expect(isSortStrategy(123)).toBe(false);
});
});
describe("sortByStrategy", () => {
interface TestItem {
name: string;
createdAt: number;
lastUsedAt: number | null;
order?: number;
}
const getters = {
getName: (item: TestItem) => item.name,
getCreatedAtMs: (item: TestItem) => item.createdAt,
getLastUsedAtMs: (item: TestItem) => item.lastUsedAt,
getManualOrder: (item: TestItem) => item.order ?? 0,
};
const items: TestItem[] = [
{ name: "Beta", createdAt: 2000, lastUsedAt: 1000, order: 2 },
{ name: "Alpha", createdAt: 1000, lastUsedAt: 3000, order: 1 },
{ name: "Gamma", createdAt: 3000, lastUsedAt: 2000, order: 3 },
];
it("sorts by recent (descending by lastUsedAt)", () => {
const sorted = sortByStrategy(items, "recent", getters);
expect(sorted.map((i) => i.name)).toEqual(["Alpha", "Gamma", "Beta"]);
});
it("sorts by created (descending by createdAt)", () => {
const sorted = sortByStrategy(items, "created", getters);
expect(sorted.map((i) => i.name)).toEqual(["Gamma", "Beta", "Alpha"]);
});
it("sorts by name (ascending alphabetically)", () => {
const sorted = sortByStrategy(items, "name", getters);
expect(sorted.map((i) => i.name)).toEqual(["Alpha", "Beta", "Gamma"]);
});
it("sorts by manual order (ascending)", () => {
const sorted = sortByStrategy(items, "manual", getters);
expect(sorted.map((i) => i.name)).toEqual(["Alpha", "Beta", "Gamma"]);
});
it("falls back to createdAt when lastUsedAt is null for recent strategy", () => {
const itemsWithNull: TestItem[] = [
{ name: "A", createdAt: 1000, lastUsedAt: null },
{ name: "B", createdAt: 3000, lastUsedAt: null },
{ name: "C", createdAt: 2000, lastUsedAt: 5000 },
];
const sorted = sortByStrategy(itemsWithNull, "recent", getters);
// C has lastUsedAt=5000, B falls back to createdAt=3000, A falls back to createdAt=1000
expect(sorted.map((i) => i.name)).toEqual(["C", "B", "A"]);
});
it("uses name as tie-breaker when timestamps are equal", () => {
const itemsWithSameTime: TestItem[] = [
{ name: "Zebra", createdAt: 1000, lastUsedAt: 2000 },
{ name: "Apple", createdAt: 1000, lastUsedAt: 2000 },
{ name: "Mango", createdAt: 1000, lastUsedAt: 2000 },
];
const sorted = sortByStrategy(itemsWithSameTime, "recent", getters);
expect(sorted.map((i) => i.name)).toEqual(["Apple", "Mango", "Zebra"]);
});
it("falls back to name sort when manual strategy but no getManualOrder", () => {
const gettersWithoutManual = {
getName: (item: TestItem) => item.name,
getCreatedAtMs: (item: TestItem) => item.createdAt,
getLastUsedAtMs: (item: TestItem) => item.lastUsedAt,
// No getManualOrder
};
const sorted = sortByStrategy(items, "manual", gettersWithoutManual);
expect(sorted.map((i) => i.name)).toEqual(["Alpha", "Beta", "Gamma"]);
});
});
describe("RecentUsageManager", () => {
describe("touch and shouldPersist", () => {
it("touch always returns current timestamp and updates memory", () => {
let currentTime = 1000;
const manager = new RecentUsageManager({
nowMs: () => currentTime,
});
const ts1 = manager.touch("key1");
expect(ts1).toBe(1000);
expect(manager.getLastTouchedAt("key1")).toBe(1000);
currentTime = 2000; // eslint-disable-line prefer-const
const ts2 = manager.touch("key1");
expect(ts2).toBe(2000);
expect(manager.getLastTouchedAt("key1")).toBe(2000);
});
it("shouldPersist returns timestamp on first touch", () => {
const currentTime = 1000;
const manager = new RecentUsageManager({
nowMs: () => currentTime,
minIntervalMs: 30000,
});
manager.touch("key1");
const result = manager.shouldPersist("key1", null);
expect(result).toBe(1000);
});
it("shouldPersist returns null when throttled (within minIntervalMs)", () => {
let currentTime = 1000;
const manager = new RecentUsageManager({
nowMs: () => currentTime,
minIntervalMs: 30000,
});
manager.touch("key1");
const firstPersist = manager.shouldPersist("key1", null);
expect(firstPersist).toBe(1000);
manager.markPersisted("key1", firstPersist!); // Mark first persistence
currentTime = 15000; // 15 seconds later
manager.touch("key1");
const result = manager.shouldPersist("key1", null);
expect(result).toBeNull(); // Throttled
});
it("shouldPersist returns timestamp after throttle period", () => {
let currentTime = 1000;
const manager = new RecentUsageManager({
nowMs: () => currentTime,
minIntervalMs: 30000,
});
manager.touch("key1");
const firstPersist = manager.shouldPersist("key1", null);
expect(firstPersist).toBe(1000);
manager.markPersisted("key1", firstPersist!); // Mark first persistence at 1000
currentTime = 35000; // 34 seconds later (past throttle)
manager.touch("key1");
const result = manager.shouldPersist("key1", null);
expect(result).toBe(35000);
});
it("shouldPersist considers persisted value for throttling", () => {
const currentTime = 50000;
const manager = new RecentUsageManager({
nowMs: () => currentTime,
minIntervalMs: 30000,
});
// Simulate: persisted value is 40000, current time is 50000
// 50000 - 40000 = 10000 < 30000, so should be throttled
manager.touch("key1");
const result = manager.shouldPersist("key1", 40000);
expect(result).toBeNull();
});
});
describe("revision and subscribe", () => {
it("increments revision and notifies subscribers on touch", () => {
const manager = new RecentUsageManager({ nowMs: () => 1000 });
const listener = jest.fn();
const unsubscribe = manager.subscribe(listener);
const initialRevision = manager.getRevision();
manager.touch("key1");
expect(manager.getRevision()).toBe(initialRevision + 1);
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
manager.touch("key1");
expect(listener).toHaveBeenCalledTimes(1); // Not called again after unsubscribe
});
it("increments revision on clear", () => {
const manager = new RecentUsageManager({ nowMs: () => 1000 });
const listener = jest.fn();
manager.subscribe(listener);
manager.touch("key1");
const revisionAfterTouch = manager.getRevision();
manager.clear("key1");
expect(manager.getRevision()).toBe(revisionAfterTouch + 1);
expect(listener).toHaveBeenCalledTimes(2); // Once for touch, once for clear
});
it("does not increment revision on shouldPersist or markPersisted", () => {
const manager = new RecentUsageManager({ nowMs: () => 1000 });
manager.touch("key1");
const revisionAfterTouch = manager.getRevision();
manager.shouldPersist("key1", null);
expect(manager.getRevision()).toBe(revisionAfterTouch);
manager.markPersisted("key1", 1000);
expect(manager.getRevision()).toBe(revisionAfterTouch);
});
});
describe("getEffectiveLastUsedAt", () => {
it("returns memory value when it is more recent", () => {
const currentTime = 5000;
const manager = new RecentUsageManager({
nowMs: () => currentTime,
});
manager.touch("key1"); // Memory = 5000
const effective = manager.getEffectiveLastUsedAt("key1", 3000); // Persisted = 3000
expect(effective).toBe(5000);
});
it("returns persisted value when memory is not set", () => {
const manager = new RecentUsageManager();
const effective = manager.getEffectiveLastUsedAt("key1", 3000);
expect(effective).toBe(3000);
});
it("returns 0 when both are null/undefined", () => {
const manager = new RecentUsageManager();
const effective = manager.getEffectiveLastUsedAt("key1", null);
expect(effective).toBe(0);
});
});
describe("clear", () => {
it("clears specific key", () => {
const currentTime = 1000;
const manager = new RecentUsageManager({
nowMs: () => currentTime,
});
manager.touch("key1");
manager.touch("key2");
manager.clear("key1");
expect(manager.getLastTouchedAt("key1")).toBeNull();
expect(manager.getLastTouchedAt("key2")).toBe(1000);
});
it("clears all keys when no argument", () => {
const currentTime = 1000;
const manager = new RecentUsageManager({
nowMs: () => currentTime,
});
manager.touch("key1");
manager.touch("key2");
manager.clear();
expect(manager.getLastTouchedAt("key1")).toBeNull();
expect(manager.getLastTouchedAt("key2")).toBeNull();
});
});
});
});

View file

@ -0,0 +1,336 @@
export const SORT_STRATEGIES = ["recent", "created", "name", "manual"] as const;
/**
* Supported sort strategies for lists that can be ordered by "recent usage".
*/
export type SortStrategy = (typeof SORT_STRATEGIES)[number];
export interface RecentUsageSortGetters<T> {
/**
* Human-readable name used for alphabetical sorting and tie-breaking.
*/
getName: (item: T) => string;
/**
* Creation time in epoch milliseconds.
*/
getCreatedAtMs: (item: T) => number;
/**
* Last-used/accessed time in epoch milliseconds.
* When missing, callers should treat it as unknown.
*/
getLastUsedAtMs: (item: T) => number | null | undefined;
/**
* Manual sort order (lower comes first). Only used when strategy is `manual`.
*/
getManualOrder?: (item: T) => number;
}
/**
* Type guard for {@link SortStrategy}.
*/
export function isSortStrategy(value: unknown): value is SortStrategy {
return typeof value === "string" && (SORT_STRATEGIES as readonly string[]).includes(value);
}
/**
* Normalize a name for consistent sorting.
*/
function normalizeName(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/**
* Normalize an epoch timestamp (ms) from unknown values.
*/
function normalizeTimestampMs(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
if (typeof value === "string") {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return numeric;
}
const parsedDate = Date.parse(value);
if (Number.isFinite(parsedDate)) {
return parsedDate;
}
}
return null;
}
/**
* Create a comparator for sorting items by {@link SortStrategy}.
*
* - `recent`: descending by `lastUsedAt` (falls back to `createdAt` when missing)
* - `created`: descending by `createdAt`
* - `name`: ascending by `name` (using default locale comparison via Intl.Collator)
* - `manual`: ascending by `manualOrder` (falls back to `name` when unavailable)
*/
export function createSortComparator<T>(
strategy: SortStrategy,
getters: RecentUsageSortGetters<T>
): (a: T, b: T) => number {
// Use default Intl.Collator to match localeCompare behavior for backwards compatibility
const collator = new Intl.Collator(undefined);
const getName = (item: T): string => normalizeName(getters.getName(item));
const getCreatedAtMs = (item: T): number => {
const createdAtMs = getters.getCreatedAtMs(item);
return Number.isFinite(createdAtMs) ? createdAtMs : 0;
};
const getEffectiveLastUsedAtMs = (item: T): number => {
const createdAtMs = getCreatedAtMs(item);
const lastUsedAtMs = normalizeTimestampMs(getters.getLastUsedAtMs(item));
return lastUsedAtMs ?? createdAtMs;
};
const getManualOrder =
typeof getters.getManualOrder === "function"
? (item: T): number => {
const order = getters.getManualOrder?.(item);
return typeof order === "number" && Number.isFinite(order) ? order : 0;
}
: null;
// Fallback to name sorting if manual strategy requested but no getManualOrder provided
const effectiveStrategy: SortStrategy =
strategy === "manual" && !getManualOrder ? "name" : strategy;
return (a: T, b: T) => {
const aName = getName(a);
const bName = getName(b);
const aCreated = getCreatedAtMs(a);
const bCreated = getCreatedAtMs(b);
const aRecent = getEffectiveLastUsedAtMs(a);
const bRecent = getEffectiveLastUsedAtMs(b);
// Primary sort based on strategy
if (effectiveStrategy === "manual") {
const aOrder = getManualOrder!(a);
const bOrder = getManualOrder!(b);
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
} else if (effectiveStrategy === "name") {
const nameDiff = collator.compare(aName, bName);
if (nameDiff !== 0) return nameDiff;
} else if (effectiveStrategy === "created") {
const createdDiff = bCreated - aCreated;
if (createdDiff !== 0) return createdDiff;
} else {
// recent
const recentDiff = bRecent - aRecent;
if (recentDiff !== 0) return recentDiff;
}
// Tie-breakers: name -> created -> recent
const fallbackNameDiff = collator.compare(aName, bName);
if (fallbackNameDiff !== 0) return fallbackNameDiff;
const fallbackCreatedDiff = bCreated - aCreated;
if (fallbackCreatedDiff !== 0) return fallbackCreatedDiff;
const fallbackRecentDiff = bRecent - aRecent;
if (fallbackRecentDiff !== 0) return fallbackRecentDiff;
return 0;
};
}
/**
* Return a new array sorted by the provided {@link SortStrategy}.
*/
export function sortByStrategy<T>(
items: readonly T[],
strategy: SortStrategy,
getters: RecentUsageSortGetters<T>
): T[] {
return [...items].sort(createSortComparator(strategy, getters));
}
export interface TouchThrottleOptions {
/**
* Minimum time between two persisted touches for the same key (per session).
* Defaults to 30 seconds.
*/
minIntervalMs?: number;
/**
* Clock source for testing.
*/
nowMs?: () => number;
}
/**
* In-memory "touch" manager with throttled persistence.
*
* Key design: separates "memory update" from "persistence decision":
* - `touch()` always updates memory and returns current timestamp (for UI sorting)
* - `shouldPersist()` decides if persistence is needed (throttled, side-effect free)
* - `markPersisted()` records a successful persistence after the side-effect succeeds
* - `getLastTouchedAt()` returns memory value (for sorting, takes priority over persisted)
* - `subscribe()` / `getRevision()` enable UI re-sorting when memory changes
*
* This ensures UI always reflects the latest access order, while disk writes are throttled.
*/
export class RecentUsageManager<Key extends string = string> {
private readonly minIntervalMs: number;
private readonly nowMs: () => number;
private readonly lastTouchedAtMsByKey: Map<Key, number> = new Map();
private readonly lastPersistedAtMsByKey: Map<Key, number> = new Map();
private revision = 0;
private readonly listeners: Set<() => void> = new Set();
constructor(options: TouchThrottleOptions = {}) {
this.minIntervalMs = options.minIntervalMs ?? 30_000;
this.nowMs = options.nowMs ?? (() => Date.now());
}
/**
* Notify subscribers after in-memory state changes.
*/
private notifyChange(): void {
this.revision += 1;
for (const listener of this.listeners) {
listener();
}
}
/**
* Return a monotonically increasing revision number that changes whenever
* in-memory touch timestamps change. Useful as a React dependency.
*/
getRevision(): number {
return this.revision;
}
/**
* Subscribe to in-memory changes (touch/clear).
*
* @param listener - Invoked after a change is recorded.
* @returns Unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
/**
* Record a touch for the given key. Always updates memory and returns current timestamp.
* Use this value for UI sorting to ensure immediate feedback.
*
* @param key - Stable key for the item (e.g. file path, project id).
* @returns Current timestamp (always returns a value, never null).
*/
touch(key: Key): number {
const now = this.nowMs();
this.lastTouchedAtMsByKey.set(key, now);
this.notifyChange();
return now;
}
/**
* Decide whether the last touch should be persisted.
*
* This method is intentionally side-effect free. Call {@link markPersisted}
* only after the persistence side-effect succeeds.
*
* @param key - Stable key for the item.
* @param persistedLastUsedAtMs - The last-used timestamp already stored on disk.
* @returns Timestamp to persist, or null if throttled.
*/
shouldPersist(key: Key, persistedLastUsedAtMs?: number | null): number | null {
const lastTouched = this.lastTouchedAtMsByKey.get(key);
if (!lastTouched) {
return null;
}
const persisted = normalizeTimestampMs(persistedLastUsedAtMs);
const lastPersisted = this.lastPersistedAtMsByKey.get(key);
// Use the most recent of persisted value and our last persistence record
const effectiveLastPersisted = Math.max(persisted ?? 0, lastPersisted ?? 0);
// If never persisted before, always allow first persistence
if (effectiveLastPersisted === 0) {
return lastTouched;
}
// Throttle: don't persist if within minIntervalMs of last persistence
if (lastTouched - effectiveLastPersisted < this.minIntervalMs) {
return null;
}
return lastTouched;
}
/**
* Mark that a touch was successfully persisted for throttling purposes.
* Call this only after the persistence side-effect succeeds.
*
* @param key - Stable key for the item.
* @param persistedAtMs - The timestamp that was persisted (epoch ms).
*/
markPersisted(key: Key, persistedAtMs: number): void {
const normalized = normalizeTimestampMs(persistedAtMs);
if (!normalized) {
return;
}
const existing = this.lastPersistedAtMsByKey.get(key) ?? 0;
this.lastPersistedAtMsByKey.set(key, Math.max(existing, normalized));
}
/**
* Get the in-memory last touched timestamp for a key.
* Use this for sorting to ensure UI reflects the latest access order.
*
* @param key - Stable key for the item.
* @returns In-memory timestamp, or null if never touched in this session.
*/
getLastTouchedAt(key: Key): number | null {
return this.lastTouchedAtMsByKey.get(key) ?? null;
}
/**
* Get the effective last used timestamp for sorting.
* Prefers in-memory value over persisted value.
*
* @param key - Stable key for the item.
* @param persistedLastUsedAtMs - The persisted timestamp from storage.
* @returns The most recent timestamp (memory or persisted).
*/
getEffectiveLastUsedAt(key: Key, persistedLastUsedAtMs?: number | null): number {
const memoryValue = this.lastTouchedAtMsByKey.get(key);
const persistedValue = normalizeTimestampMs(persistedLastUsedAtMs);
return Math.max(memoryValue ?? 0, persistedValue ?? 0);
}
/**
* Clear state for a specific key or for all keys.
*/
clear(key?: Key): void {
if (key) {
this.lastTouchedAtMsByKey.delete(key);
this.lastPersistedAtMsByKey.delete(key);
this.notifyChange();
return;
}
this.lastTouchedAtMsByKey.clear();
this.lastPersistedAtMsByKey.clear();
this.notifyChange();
}
}