import type { AgentMentionProviderQueryDiagnostic } from "./agentMentionSearchDiagnostics"; import type { AgentMentionFilterId, AgentMentionIssueTopicGroup, AgentMentionLifecycleDiagnosticLog, AgentMentionRawGroups, AgentMentionTotalCounts } from "./AgentMentionSearchContracts"; import { elapsedDiagnosticMs, providerDiagnosticsSummary, rawGroupItemCount } from "./AgentMentionSearchModel"; import { AbortableSingleFlight } from "../../shared/query/abortableSingleFlight"; export interface AgentMentionBrowseFetchResult { providerDiagnostics: AgentMentionProviderQueryDiagnostic[]; rawGroups: AgentMentionRawGroups; totalCounts: AgentMentionTotalCounts; issueTopicGroups: AgentMentionIssueTopicGroup[] | null; } export interface AgentMentionBrowseCacheEntry extends AgentMentionBrowseFetchResult { cachedAt: number; } export type AgentMentionBrowseLoadReason = "open" | "preload"; const sharedAgentMentionBrowseCache = new Map< string, AgentMentionBrowseCacheEntry >(); const sharedAgentMentionBrowseFetches = new AbortableSingleFlight< string, AgentMentionBrowseFetchResult >(); // Bound the shared browse cache so long-lived renderer sessions cannot grow it // without limit. Eviction happens on write (LRU-by-insertion-order); reads keep // returning stale entries so the stale-while-revalidate path stays intact. export const MAX_BROWSE_CACHE_ENTRIES = 64; function writeBrowseCacheEntry( cacheKey: string, entry: AgentMentionBrowseCacheEntry ): void { // Re-insert so the freshly written key becomes the newest (Map preserves // insertion order), then drop the oldest keys past the cap. sharedAgentMentionBrowseCache.delete(cacheKey); sharedAgentMentionBrowseCache.set(cacheKey, entry); while (sharedAgentMentionBrowseCache.size > MAX_BROWSE_CACHE_ENTRIES) { const oldestKey = sharedAgentMentionBrowseCache.keys().next().value; if (oldestKey === undefined) { break; } sharedAgentMentionBrowseCache.delete(oldestKey); } } export function mergeAgentMentionBrowseIssueGroupPage(input: { cacheKey: string; group: AgentMentionIssueTopicGroup; cachedAt: number; }): void { const entry = sharedAgentMentionBrowseCache.get(input.cacheKey); if (!entry?.issueTopicGroups) { return; } const groupIndex = entry.issueTopicGroups.findIndex( (group) => group.id === input.group.id ); if (groupIndex < 0) { return; } const previous = entry.issueTopicGroups[groupIndex]; if (!previous) { return; } const seen = new Set( previous.items .filter((item) => item.kind === "workspace-issue") .map((item) => item.targetId) ); const appended = input.group.items.filter((item) => { if (item.kind !== "workspace-issue") { return true; } if (seen.has(item.targetId)) { return false; } seen.add(item.targetId); return true; }); const mergedItems = [...previous.items, ...appended]; const groups = entry.issueTopicGroups.map((group, index) => index === groupIndex ? { ...previous, items: mergedItems, totalCount: Math.max(input.group.totalCount, mergedItems.length), nextPageToken: input.group.nextPageToken, loadMoreStatus: "idle" as const, loadMoreError: null } : group ); writeBrowseCacheEntry(input.cacheKey, { ...entry, issueTopicGroups: groups, cachedAt: input.cachedAt }); } // Defer speculative warm-up to a browser idle slot so it never blocks the // caller's synchronous path (e.g. a composer focus handler) or a render commit. // Falls back to a macrotask where requestIdleCallback is unavailable (jsdom, // older runtimes). Returns a canceller the owner uses on teardown. export function scheduleAgentMentionIdleTask(task: () => void): () => void { const scope = globalThis as typeof globalThis & { requestIdleCallback?: ( cb: () => void, opts?: { timeout: number } ) => number; cancelIdleCallback?: (handle: number) => void; }; if (typeof scope.requestIdleCallback === "function") { const handle = scope.requestIdleCallback(() => task(), { timeout: 500 }); return () => scope.cancelIdleCallback?.(handle); } // timing: requestIdleCallback fallback for runtimes that lack it; run on next tick const handle = setTimeout(task, 0); return () => clearTimeout(handle); } export function resetAgentMentionSearchBrowseCacheForTests(): void { sharedAgentMentionBrowseCache.clear(); sharedAgentMentionBrowseFetches.abortAll(); } ( globalThis as typeof globalThis & { __tuttiResetAgentMentionSearchBrowseCacheForTests?: () => void; } ).__tuttiResetAgentMentionSearchBrowseCacheForTests = resetAgentMentionSearchBrowseCacheForTests; // Resolve filter tab labels lazily so they reflect the active i18n locale at the // time a state is emitted. Computing this at module load froze the labels to the export async function loadAgentMentionBrowseFetchResult(input: { input: { workspaceId: string; currentUserId: string; filter: AgentMentionFilterId; sessionCwd: string; sectionKey: string; }; cacheKey: string; reason: AgentMentionBrowseLoadReason; diagnosticNow: () => number; providerIds: string; abortSignal?: AbortSignal; fetchBrowseResult: ( abortSignal: AbortSignal ) => Promise; logLifecycle: ( event: AgentMentionLifecycleDiagnosticLog["event"], details: AgentMentionLifecycleDiagnosticLog["details"] ) => void; }): Promise { const { cacheKey, reason } = input; const browseInput = input.input; const request = sharedAgentMentionBrowseFetches.acquire( cacheKey, ({ signal: abortSignal }) => { const startedAt = input.diagnosticNow(); input.logLifecycle("browse.fetch.start", { filter: browseInput.filter, providerIds: input.providerIds, reason, workspaceId: browseInput.workspaceId }); return input.fetchBrowseResult(abortSignal).then((result) => { if (abortSignal.aborted) { const error = new Error("Mention browse request aborted"); error.name = "AbortError"; throw error; } writeBrowseCacheEntry(cacheKey, { ...result, cachedAt: input.diagnosticNow() }); input.logLifecycle("browse.fetch.success", { durationMs: elapsedDiagnosticMs(input.diagnosticNow(), startedAt), filter: browseInput.filter, itemCount: rawGroupItemCount(result.rawGroups), providerResults: providerDiagnosticsSummary( result.providerDiagnostics ), reason, workspaceId: browseInput.workspaceId }); return result; }); }, input.abortSignal ); if (request.shared) { input.logLifecycle("browse.fetch.dedupe", { filter: browseInput.filter, reason, workspaceId: browseInput.workspaceId }); } return request.promise; } export function readAgentMentionBrowseCache(input: { cacheKey: string; browseCacheTtlMs: number; diagnosticNow: () => number; }): { entry: AgentMentionBrowseCacheEntry | null; isFresh: boolean; } { const entry = sharedAgentMentionBrowseCache.get(input.cacheKey); if (!entry) { return { entry: null, isFresh: false }; } // Touch for LRU recency. We deliberately keep returning stale entries (no // delete here) so the stale-while-revalidate path can still surface them. sharedAgentMentionBrowseCache.delete(input.cacheKey); sharedAgentMentionBrowseCache.set(input.cacheKey, entry); const ageMs = input.diagnosticNow() - entry.cachedAt; const isFresh = input.browseCacheTtlMs >= 0 && Number.isFinite(input.browseCacheTtlMs) && ageMs <= input.browseCacheTtlMs; return { entry, isFresh }; } export function buildAgentMentionBrowseCacheKey(input: { workspaceId: string; currentUserId: string; filter: AgentMentionFilterId; sessionCwd: string; sectionKey: string; fileLimit: number; issueLimit: number; providerIds: readonly string[]; provenanceFilterKey?: string; }): string { return JSON.stringify({ workspaceId: input.workspaceId, currentUserId: input.currentUserId, sessionCwd: input.sessionCwd, sectionKey: input.sectionKey, filter: input.filter, fileLimit: input.fileLimit, issueLimit: input.issueLimit, providerIds: input.providerIds, provenanceFilterKey: input.provenanceFilterKey ?? "disabled" }); }