UI: consolidate stale request handling in skills and usage

This commit is contained in:
joshavant
2026-04-09 20:16:16 -05:00
committed by Josh Avant
parent 6a21c0fba9
commit db039d994d
2 changed files with 75 additions and 62 deletions

View File

@@ -87,6 +87,31 @@ function getErrorMessage(err: unknown) {
return String(err);
}
async function runStaleAwareRequest<T>(
isCurrent: () => boolean,
request: () => Promise<T>,
onSuccess: (value: T) => void,
onError: (err: unknown) => void,
onFinally: () => void,
) {
try {
const result = await request();
if (!isCurrent()) {
return;
}
onSuccess(result);
} catch (err) {
if (!isCurrent()) {
return;
}
onError(err);
} finally {
if (isCurrent()) {
onFinally();
}
}
}
export function setClawHubSearchQuery(state: SkillsState, query: string) {
state.clawhubSearchQuery = query;
state.clawhubInstallMessage = null;
@@ -202,56 +227,53 @@ export async function searchClawHub(state: SkillsState, query: string) {
state.clawhubSearchLoading = false;
return;
}
const client = state.client;
// Clear stale entries as soon as a new search begins so the UI cannot act on
// results that no longer match the current query while the next request is in flight.
state.clawhubSearchResults = null;
state.clawhubSearchLoading = true;
state.clawhubSearchError = null;
try {
const res = await state.client.request<{ results: ClawHubSearchResult[] }>("skills.search", {
query,
limit: 20,
});
if (query !== state.clawhubSearchQuery) {
return;
}
state.clawhubSearchResults = res?.results ?? [];
} catch (err) {
if (query !== state.clawhubSearchQuery) {
return;
}
state.clawhubSearchError = getErrorMessage(err);
} finally {
if (query === state.clawhubSearchQuery) {
await runStaleAwareRequest(
() => query === state.clawhubSearchQuery,
() =>
client.request<{ results: ClawHubSearchResult[] }>("skills.search", {
query,
limit: 20,
}),
(res) => {
state.clawhubSearchResults = res?.results ?? [];
},
(err) => {
state.clawhubSearchError = getErrorMessage(err);
},
() => {
state.clawhubSearchLoading = false;
}
}
},
);
}
export async function loadClawHubDetail(state: SkillsState, slug: string) {
if (!state.client || !state.connected) {
return;
}
const client = state.client;
state.clawhubDetailSlug = slug;
state.clawhubDetailLoading = true;
state.clawhubDetailError = null;
state.clawhubDetail = null;
try {
const res = await state.client.request<ClawHubSkillDetail>("skills.detail", { slug });
if (slug !== state.clawhubDetailSlug) {
return;
}
state.clawhubDetail = res ?? null;
} catch (err) {
if (slug !== state.clawhubDetailSlug) {
return;
}
state.clawhubDetailError = getErrorMessage(err);
} finally {
if (slug === state.clawhubDetailSlug) {
await runStaleAwareRequest(
() => slug === state.clawhubDetailSlug,
() => client.request<ClawHubSkillDetail>("skills.detail", { slug }),
(res) => {
state.clawhubDetail = res ?? null;
},
(err) => {
state.clawhubDetailError = getErrorMessage(err);
},
() => {
state.clawhubDetailLoading = false;
}
}
},
);
}
export function closeClawHubDetail(state: SkillsState) {

View File

@@ -29,10 +29,8 @@ export type UsageState = {
settings?: { gatewayUrl?: string };
};
type DateInterpretationMode = "utc" | "gateway" | "specific";
type UsageDateInterpretationParams = {
mode: DateInterpretationMode;
mode: "utc" | "specific";
utcOffset?: string;
};
@@ -105,17 +103,15 @@ function normalizeGatewayCompatibilityKey(gatewayUrl?: string): string {
}
}
function resolveGatewayCompatibilityKey(state: UsageState): string {
return normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl);
}
function shouldSendLegacyDateInterpretation(state: UsageState): boolean {
return !getLegacyUsageDateParamsCache().has(resolveGatewayCompatibilityKey(state));
return !getLegacyUsageDateParamsCache().has(
normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl),
);
}
function rememberLegacyDateInterpretation(state: UsageState) {
const cache = getLegacyUsageDateParamsCache();
cache.add(resolveGatewayCompatibilityKey(state));
cache.add(normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl));
persistLegacyUsageDateParamsCache(cache);
}
@@ -143,11 +139,7 @@ const formatUtcOffset = (timezoneOffsetMinutes: number): string => {
const buildDateInterpretationParams = (
timeZone: "local" | "utc",
includeDateInterpretation: boolean,
): UsageDateInterpretationParams | undefined => {
if (!includeDateInterpretation) {
return undefined;
}
): UsageDateInterpretationParams => {
if (timeZone === "utc") {
return { mode: "utc" };
}
@@ -174,6 +166,15 @@ function toErrorMessage(err: unknown): string {
return "request failed";
}
function applyUsageResults(state: UsageState, sessionsRes: unknown, costRes: unknown) {
if (sessionsRes) {
state.usageResult = sessionsRes as SessionsUsageResult;
}
if (costRes) {
state.usageCostSummary = costRes as CostUsageSummary;
}
}
export async function loadUsage(
state: UsageState,
overrides?: {
@@ -192,10 +193,9 @@ export async function loadUsage(
const startDate = overrides?.startDate ?? state.usageStartDate;
const endDate = overrides?.endDate ?? state.usageEndDate;
const runUsageRequests = (includeDateInterpretation: boolean) => {
const dateInterpretation = buildDateInterpretationParams(
state.usageTimeZone,
includeDateInterpretation,
);
const dateInterpretation = includeDateInterpretation
? buildDateInterpretationParams(state.usageTimeZone)
: undefined;
return Promise.all([
client.request("sessions.usage", {
startDate,
@@ -212,26 +212,17 @@ export async function loadUsage(
]);
};
const applyUsageResults = (sessionsRes: unknown, costRes: unknown) => {
if (sessionsRes) {
state.usageResult = sessionsRes as SessionsUsageResult;
}
if (costRes) {
state.usageCostSummary = costRes as CostUsageSummary;
}
};
const includeDateInterpretation = shouldSendLegacyDateInterpretation(state);
try {
const [sessionsRes, costRes] = await runUsageRequests(includeDateInterpretation);
applyUsageResults(sessionsRes, costRes);
applyUsageResults(state, sessionsRes, costRes);
} catch (err) {
if (includeDateInterpretation && isLegacyDateInterpretationUnsupportedError(err)) {
// Older gateways reject `mode`/`utcOffset` in `sessions.usage`.
// Remember this per gateway and retry once without those fields.
rememberLegacyDateInterpretation(state);
const [sessionsRes, costRes] = await runUsageRequests(false);
applyUsageResults(sessionsRes, costRes);
applyUsageResults(state, sessionsRes, costRes);
} else {
throw err;
}