diff --git a/src/browser/features/LandingPage/LandingPage.tsx b/src/browser/features/LandingPage/LandingPage.tsx index d8b0ec2411..85b78b9cac 100644 --- a/src/browser/features/LandingPage/LandingPage.tsx +++ b/src/browser/features/LandingPage/LandingPage.tsx @@ -328,10 +328,7 @@ function ProjectsSection(props: { dateFilters: DateFilters }) {

Projects

{projects.map((project) => ( -
+
{project.projectName} diff --git a/src/browser/features/RightSidebar/DevToolsTab/DevToolsStepCard.tsx b/src/browser/features/RightSidebar/DevToolsTab/DevToolsStepCard.tsx index 7628732d0f..de507c6410 100644 --- a/src/browser/features/RightSidebar/DevToolsTab/DevToolsStepCard.tsx +++ b/src/browser/features/RightSidebar/DevToolsTab/DevToolsStepCard.tsx @@ -273,8 +273,8 @@ function ProviderOptionsSection(props: { providerOptions: unknown }) { } function TokenUsageSection(props: { usage: DevToolsUsage }) { - const inputTotal = getTokenTotalOrUndefined(props.usage.inputTokens); - const outputTotal = getTokenTotalOrUndefined(props.usage.outputTokens); + const inputTotal = getTokenTotal(props.usage.inputTokens); + const outputTotal = getTokenTotal(props.usage.outputTokens); const inputBreakdown = isInputTokenBreakdown(props.usage.inputTokens) ? props.usage.inputTokens : null; @@ -700,16 +700,6 @@ function getCombinedTotal( return (inputTotal ?? 0) + (outputTotal ?? 0); } -function getTokenTotalOrUndefined( - value: number | { total: number } | undefined -): number | undefined { - if (value == null) { - return undefined; - } - - return getTokenTotal(value); -} - function formatTokenCount(value: number | undefined): string { return value == null ? "—" : value.toLocaleString(); } diff --git a/src/browser/features/Tools/Shared/toolUtils.tsx b/src/browser/features/Tools/Shared/toolUtils.tsx index 19b35f0c65..152b61797e 100644 --- a/src/browser/features/Tools/Shared/toolUtils.tsx +++ b/src/browser/features/Tools/Shared/toolUtils.tsx @@ -78,21 +78,6 @@ export function getStatusDisplay(status: ToolStatus): React.ReactNode { } } -/** - * Format a value for display (JSON or string) - */ -export function formatValue(value: unknown): string { - if (value === null || value === undefined) return "None"; - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") return String(value); - try { - return JSON.stringify(value, null, 2); - } catch { - // If JSON.stringify fails (e.g., circular reference), return a safe fallback - return "[Complex Object - Cannot Stringify]"; - } -} - /** * Type guard for ToolErrorResult shape: { success: false, error: string }. * Use this when you need type narrowing to access error. diff --git a/src/browser/hooks/useAnalytics.ts b/src/browser/hooks/useAnalytics.ts index 09e1f26468..62e006b424 100644 --- a/src/browser/hooks/useAnalytics.ts +++ b/src/browser/hooks/useAnalytics.ts @@ -1,4 +1,5 @@ import assert from "@/common/utils/assert"; +import type React from "react"; import { useEffect, useState } from "react"; import type { z } from "zod"; import type { APIClient } from "@/browser/contexts/API"; @@ -85,6 +86,61 @@ function getAnalyticsNamespace(api: APIClient): AnalyticsNamespace | null { return maybeNamespace as AnalyticsNamespace; } +/** + * Shared effect body for analytics data-fetching hooks. + * Handles API readiness check, cancellation on unmount/re-render, and error handling. + * Returns the effect cleanup function (or undefined for early-exit paths). + */ +function runAnalyticsEffect( + api: APIClient | null, + setState: React.Dispatch>>, + fetcher: (analyticsApi: AnalyticsNamespace) => Promise +): (() => void) | undefined { + if (!api) { + setState((previousState) => ({ + data: previousState.data, + loading: true, + error: null, + })); + return; + } + + const analyticsApi = getAnalyticsNamespace(api); + if (!analyticsApi) { + setState({ data: null, loading: false, error: ANALYTICS_UNAVAILABLE_MESSAGE }); + return; + } + + let ignore = false; + setState((previousState) => ({ + data: previousState.data, + loading: true, + error: null, + })); + + void fetcher(analyticsApi) + .then((data) => { + if (ignore) { + return; + } + setState({ data, loading: false, error: null }); + }) + .catch((error: unknown) => { + if (ignore) { + return; + } + setState((previousState) => ({ + data: previousState.data, + loading: false, + error: getErrorMessage(error), + })); + }); + + return () => { + ignore = true; + }; +} + export function useAnalyticsSummary( projectPath?: string | null, dateFilters?: DateFilterParams @@ -100,53 +156,11 @@ export function useAnalyticsSummary( }); useEffect(() => { - if (!api) { - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - return; - } - - const analyticsApi = getAnalyticsNamespace(api); - if (!analyticsApi) { - setState({ data: null, loading: false, error: ANALYTICS_UNAVAILABLE_MESSAGE }); - return; - } - - let ignore = false; - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - const fromDate = fromMs == null ? null : new Date(fromMs); const toDate = toMs == null ? null : new Date(toMs); - - void analyticsApi - .getSummary({ projectPath: projectPath ?? null, from: fromDate, to: toDate }) - .then((data) => { - if (ignore) { - return; - } - setState({ data, loading: false, error: null }); - }) - .catch((error: unknown) => { - if (ignore) { - return; - } - setState((previousState) => ({ - data: previousState.data, - loading: false, - error: getErrorMessage(error), - })); - }); - - return () => { - ignore = true; - }; + return runAnalyticsEffect(api, setState, (analyticsApi) => + analyticsApi.getSummary({ projectPath: projectPath ?? null, from: fromDate, to: toDate }) + ); }, [api, projectPath, fromMs, toMs]); return state; @@ -174,58 +188,16 @@ export function useAnalyticsSpendOverTime(params: { }); useEffect(() => { - if (!api) { - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - return; - } - - const analyticsApi = getAnalyticsNamespace(api); - if (!analyticsApi) { - setState({ data: null, loading: false, error: ANALYTICS_UNAVAILABLE_MESSAGE }); - return; - } - - let ignore = false; - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - const fromDate = fromMs == null ? null : new Date(fromMs); const toDate = toMs == null ? null : new Date(toMs); - - void analyticsApi - .getSpendOverTime({ + return runAnalyticsEffect(api, setState, (analyticsApi) => + analyticsApi.getSpendOverTime({ projectPath: params.projectPath ?? null, granularity: params.granularity, from: fromDate, to: toDate, }) - .then((data) => { - if (ignore) { - return; - } - setState({ data, loading: false, error: null }); - }) - .catch((error: unknown) => { - if (ignore) { - return; - } - setState((previousState) => ({ - data: previousState.data, - loading: false, - error: getErrorMessage(error), - })); - }); - - return () => { - ignore = true; - }; + ); }, [api, params.projectPath, params.granularity, fromMs, toMs]); return state; @@ -245,53 +217,11 @@ export function useAnalyticsSpendByProject( }); useEffect(() => { - if (!api) { - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - return; - } - - const analyticsApi = getAnalyticsNamespace(api); - if (!analyticsApi) { - setState({ data: null, loading: false, error: ANALYTICS_UNAVAILABLE_MESSAGE }); - return; - } - - let ignore = false; - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - const fromDate = fromMs == null ? null : new Date(fromMs); const toDate = toMs == null ? null : new Date(toMs); - - void analyticsApi - .getSpendByProject({ from: fromDate, to: toDate }) - .then((data) => { - if (ignore) { - return; - } - setState({ data, loading: false, error: null }); - }) - .catch((error: unknown) => { - if (ignore) { - return; - } - setState((previousState) => ({ - data: previousState.data, - loading: false, - error: getErrorMessage(error), - })); - }); - - return () => { - ignore = true; - }; + return runAnalyticsEffect(api, setState, (analyticsApi) => + analyticsApi.getSpendByProject({ from: fromDate, to: toDate }) + ); }, [api, fromMs, toMs]); return state; @@ -312,53 +242,11 @@ export function useAnalyticsSpendByModel( }); useEffect(() => { - if (!api) { - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - return; - } - - const analyticsApi = getAnalyticsNamespace(api); - if (!analyticsApi) { - setState({ data: null, loading: false, error: ANALYTICS_UNAVAILABLE_MESSAGE }); - return; - } - - let ignore = false; - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - const fromDate = fromMs == null ? null : new Date(fromMs); const toDate = toMs == null ? null : new Date(toMs); - - void analyticsApi - .getSpendByModel({ projectPath: projectPath ?? null, from: fromDate, to: toDate }) - .then((data) => { - if (ignore) { - return; - } - setState({ data, loading: false, error: null }); - }) - .catch((error: unknown) => { - if (ignore) { - return; - } - setState((previousState) => ({ - data: previousState.data, - loading: false, - error: getErrorMessage(error), - })); - }); - - return () => { - ignore = true; - }; + return runAnalyticsEffect(api, setState, (analyticsApi) => + analyticsApi.getSpendByModel({ projectPath: projectPath ?? null, from: fromDate, to: toDate }) + ); }, [api, projectPath, fromMs, toMs]); return state; @@ -379,53 +267,15 @@ export function useAnalyticsTokensByModel( }); useEffect(() => { - if (!api) { - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - return; - } - - const analyticsApi = getAnalyticsNamespace(api); - if (!analyticsApi) { - setState({ data: null, loading: false, error: ANALYTICS_UNAVAILABLE_MESSAGE }); - return; - } - - let ignore = false; - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - const fromDate = fromMs == null ? null : new Date(fromMs); const toDate = toMs == null ? null : new Date(toMs); - - void analyticsApi - .getTokensByModel({ projectPath: projectPath ?? null, from: fromDate, to: toDate }) - .then((data) => { - if (ignore) { - return; - } - setState({ data, loading: false, error: null }); + return runAnalyticsEffect(api, setState, (analyticsApi) => + analyticsApi.getTokensByModel({ + projectPath: projectPath ?? null, + from: fromDate, + to: toDate, }) - .catch((error: unknown) => { - if (ignore) { - return; - } - setState((previousState) => ({ - data: previousState.data, - loading: false, - error: getErrorMessage(error), - })); - }); - - return () => { - ignore = true; - }; + ); }, [api, projectPath, fromMs, toMs]); return state; @@ -452,58 +302,16 @@ export function useAnalyticsTimingDistribution( }); useEffect(() => { - if (!api) { - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - return; - } - - const analyticsApi = getAnalyticsNamespace(api); - if (!analyticsApi) { - setState({ data: null, loading: false, error: ANALYTICS_UNAVAILABLE_MESSAGE }); - return; - } - - let ignore = false; - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - const fromDate = fromMs == null ? null : new Date(fromMs); const toDate = toMs == null ? null : new Date(toMs); - - void analyticsApi - .getTimingDistribution({ + return runAnalyticsEffect(api, setState, (analyticsApi) => + analyticsApi.getTimingDistribution({ metric, projectPath: projectPath ?? null, from: fromDate, to: toDate, }) - .then((data) => { - if (ignore) { - return; - } - setState({ data, loading: false, error: null }); - }) - .catch((error: unknown) => { - if (ignore) { - return; - } - setState((previousState) => ({ - data: previousState.data, - loading: false, - error: getErrorMessage(error), - })); - }); - - return () => { - ignore = true; - }; + ); }, [api, metric, projectPath, fromMs, toMs]); return state; @@ -524,53 +332,15 @@ export function useAnalyticsProviderCacheHitRatio( }); useEffect(() => { - if (!api) { - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - return; - } - - const analyticsApi = getAnalyticsNamespace(api); - if (!analyticsApi) { - setState({ data: null, loading: false, error: ANALYTICS_UNAVAILABLE_MESSAGE }); - return; - } - - let ignore = false; - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - const fromDate = fromMs == null ? null : new Date(fromMs); const toDate = toMs == null ? null : new Date(toMs); - - void analyticsApi - .getCacheHitRatioByProvider({ projectPath: projectPath ?? null, from: fromDate, to: toDate }) - .then((data) => { - if (ignore) { - return; - } - setState({ data, loading: false, error: null }); + return runAnalyticsEffect(api, setState, (analyticsApi) => + analyticsApi.getCacheHitRatioByProvider({ + projectPath: projectPath ?? null, + from: fromDate, + to: toDate, }) - .catch((error: unknown) => { - if (ignore) { - return; - } - setState((previousState) => ({ - data: previousState.data, - loading: false, - error: getErrorMessage(error), - })); - }); - - return () => { - ignore = true; - }; + ); }, [api, projectPath, fromMs, toMs]); return state; @@ -591,53 +361,15 @@ export function useAnalyticsAgentCostBreakdown( }); useEffect(() => { - if (!api) { - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - return; - } - - const analyticsApi = getAnalyticsNamespace(api); - if (!analyticsApi) { - setState({ data: null, loading: false, error: ANALYTICS_UNAVAILABLE_MESSAGE }); - return; - } - - let ignore = false; - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - const fromDate = fromMs == null ? null : new Date(fromMs); const toDate = toMs == null ? null : new Date(toMs); - - void analyticsApi - .getAgentCostBreakdown({ projectPath: projectPath ?? null, from: fromDate, to: toDate }) - .then((data) => { - if (ignore) { - return; - } - setState({ data, loading: false, error: null }); + return runAnalyticsEffect(api, setState, (analyticsApi) => + analyticsApi.getAgentCostBreakdown({ + projectPath: projectPath ?? null, + from: fromDate, + to: toDate, }) - .catch((error: unknown) => { - if (ignore) { - return; - } - setState((previousState) => ({ - data: previousState.data, - loading: false, - error: getErrorMessage(error), - })); - }); - - return () => { - ignore = true; - }; + ); }, [api, projectPath, fromMs, toMs]); return state; @@ -658,53 +390,15 @@ export function useAnalyticsDelegationSummary( }); useEffect(() => { - if (!api) { - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - return; - } - - const analyticsApi = getAnalyticsNamespace(api); - if (!analyticsApi) { - setState({ data: null, loading: false, error: ANALYTICS_UNAVAILABLE_MESSAGE }); - return; - } - - let ignore = false; - setState((previousState) => ({ - data: previousState.data, - loading: true, - error: null, - })); - const fromDate = fromMs == null ? null : new Date(fromMs); const toDate = toMs == null ? null : new Date(toMs); - - void analyticsApi - .getDelegationSummary({ projectPath: projectPath ?? null, from: fromDate, to: toDate }) - .then((data) => { - if (ignore) { - return; - } - setState({ data, loading: false, error: null }); + return runAnalyticsEffect(api, setState, (analyticsApi) => + analyticsApi.getDelegationSummary({ + projectPath: projectPath ?? null, + from: fromDate, + to: toDate, }) - .catch((error: unknown) => { - if (ignore) { - return; - } - setState((previousState) => ({ - data: previousState.data, - loading: false, - error: getErrorMessage(error), - })); - }); - - return () => { - ignore = true; - }; + ); }, [api, projectPath, fromMs, toMs]); return state; diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 42217b29fd..43b2b0f45e 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -2982,8 +2982,7 @@ export class WorkspaceStore { const onClientChange = () => attemptController.abort(); clientChangeSignal.addEventListener("abort", onClientChange, { once: true }); - let stallInterval: ReturnType | null = null; - let lastChatEventAt = Date.now(); + const watchdog = this.createStallWatchdog(attemptController, `onChat(${workspaceId})`); try { // Always reset caughtUp at subscription start so historical events are @@ -3049,26 +3048,16 @@ export class WorkspaceStore { this.resetChatStateForReplay(workspaceId); } - // Stall watchdog: server sends heartbeats every 5s, so if we don't receive ANY events - // (including heartbeats) for 10s, the connection is likely dead. - stallInterval = setInterval(() => { - if (attemptController.signal.aborted) return; - - const elapsedMs = Date.now() - lastChatEventAt; - if (elapsedMs < SUBSCRIPTION_STALL_TIMEOUT_MS) return; - - console.warn( - `[WorkspaceStore] onChat appears stalled for ${workspaceId} (no events for ${elapsedMs}ms); retrying...` - ); - attemptController.abort(); - }, SUBSCRIPTION_STALL_CHECK_INTERVAL_MS); + // Start watchdog after subscribe connects so timeout measures + // post-connect silence, not handshake latency. + watchdog.start(); for await (const data of iterator) { if (signal.aborted) { return; } - lastChatEventAt = Date.now(); + watchdog.markEvent(); // Connection is alive again - don't carry old backoff into the next failure. attempt = 0; @@ -3134,9 +3123,7 @@ export class WorkspaceStore { } finally { signal.removeEventListener("abort", onAbort); clientChangeSignal.removeEventListener("abort", onClientChange); - if (stallInterval) { - clearInterval(stallInterval); - } + watchdog.stop(); } if (this.isWorkspaceRegistered(workspaceId)) { diff --git a/src/node/config.ts b/src/node/config.ts index ffda84f6c1..4294972d78 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -5,7 +5,12 @@ import * as jsonc from "jsonc-parser"; import writeFileAtomic from "write-file-atomic"; import { log } from "@/node/services/log"; import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import { type Secret, type SecretsConfig } from "@/common/types/secrets"; +import { + isSecretReferenceValue, + isOpSecretValue, + type Secret, + type SecretsConfig, +} from "@/common/types/secrets"; import type { Workspace, ProjectConfig, @@ -1203,30 +1208,12 @@ ${jsonString}`; return stripTrailingSlashes(projectPath); } - private static isSecretReferenceValue(value: unknown): value is { secret: string } { - return ( - typeof value === "object" && - value !== null && - "secret" in value && - typeof (value as { secret?: unknown }).secret === "string" - ); - } - - private static isOpSecretValue(value: unknown): value is { op: string } { - return ( - typeof value === "object" && - value !== null && - "op" in value && - typeof (value as { op?: unknown }).op === "string" - ); - } - private static isSecretValue(value: unknown): value is Secret["value"] { if (typeof value === "string") { return true; } - return Config.isSecretReferenceValue(value) || Config.isOpSecretValue(value); + return isSecretReferenceValue(value) || isOpSecretValue(value); } private static isSecret(value: unknown): value is Secret { @@ -1383,12 +1370,12 @@ ${jsonString}`; try { const raw = globalRawByKey.get(key); - if (typeof raw === "string" || Config.isOpSecretValue(raw)) { + if (typeof raw === "string" || isOpSecretValue(raw)) { globalResolved.set(key, raw); return raw; } - if (Config.isSecretReferenceValue(raw)) { + if (isSecretReferenceValue(raw)) { const target = raw.secret.trim(); if (!target) { globalResolved.set(key, undefined); @@ -1416,7 +1403,7 @@ ${jsonString}`; } return projectSecrets.map((secret) => { - if (!Config.isSecretReferenceValue(secret.value)) { + if (!isSecretReferenceValue(secret.value)) { return secret; }