Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
265 lines
8.3 KiB
TypeScript
265 lines
8.3 KiB
TypeScript
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<AgentMentionBrowseFetchResult>;
|
|
logLifecycle: (
|
|
event: AgentMentionLifecycleDiagnosticLog["event"],
|
|
details: AgentMentionLifecycleDiagnosticLog["details"]
|
|
) => void;
|
|
}): Promise<AgentMentionBrowseFetchResult> {
|
|
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"
|
|
});
|
|
}
|