diff --git a/src/components/common/SecondaryScreenShell.module.scss b/src/components/common/SecondaryScreenShell.module.scss index 1f86817..23889e6 100644 --- a/src/components/common/SecondaryScreenShell.module.scss +++ b/src/components/common/SecondaryScreenShell.module.scss @@ -82,3 +82,51 @@ gap: $spacing-lg; } +.contentWithFloatingAction { + padding-bottom: calc( + var(--secondary-shell-floating-action-height, 56px) + 12px + env(safe-area-inset-bottom) + ); +} + +.floatingActionContainer { + position: fixed; + left: var(--content-center-x, 50%); + bottom: calc(12px + env(safe-area-inset-bottom)); + transform: translateX(-50%); + z-index: 50; + pointer-events: none; + width: fit-content; + max-width: calc(100vw - 24px); +} + +.floatingActionSurface { + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--bg-primary) 82%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); +} + +:global([data-theme='dark']) { + .floatingActionSurface { + background: color-mix(in srgb, var(--bg-primary) 82%, transparent); + border-color: color-mix(in srgb, var(--border-color) 55%, transparent); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + } +} + +@media (max-width: 1200px) { + .floatingActionContainer { + max-width: calc(100vw - 16px); + } + + .floatingActionSurface { + padding: 8px 10px; + } +} diff --git a/src/components/common/SecondaryScreenShell.tsx b/src/components/common/SecondaryScreenShell.tsx index 177a29f..f14e3f2 100644 --- a/src/components/common/SecondaryScreenShell.tsx +++ b/src/components/common/SecondaryScreenShell.tsx @@ -1,7 +1,9 @@ -import { forwardRef, type ReactNode } from 'react'; +import { forwardRef, useLayoutEffect, useRef, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; import { Button } from '@/components/ui/Button'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { IconChevronLeft } from '@/components/ui/icons'; +import { usePageTransitionLayer } from './PageTransitionLayer'; import styles from './SecondaryScreenShell.module.scss'; export type SecondaryScreenShellProps = { @@ -10,6 +12,9 @@ export type SecondaryScreenShellProps = { backLabel?: string; backAriaLabel?: string; rightAction?: ReactNode; + hideTopBarBackButton?: boolean; + hideTopBarRightAction?: boolean; + floatingAction?: ReactNode; isLoading?: boolean; loadingLabel?: ReactNode; className?: string; @@ -25,6 +30,9 @@ export const SecondaryScreenShell = forwardRef(null); + + useLayoutEffect(() => { + if (!shouldRenderFloatingAction) return; + + const element = floatingActionRef.current; + if (!element) return; + + const updateHeight = () => { + const height = element.getBoundingClientRect().height; + document.documentElement.style.setProperty( + '--secondary-shell-floating-action-height', + `${height}px` + ); + }; + + updateHeight(); + window.addEventListener('resize', updateHeight); + + const resizeObserver = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateHeight); + resizeObserver?.observe(element); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateHeight); + document.documentElement.style.removeProperty('--secondary-shell-floating-action-height'); + }; + }, [shouldRenderFloatingAction]); return ( -
-
- {onBack ? ( - - ) : ( -
- )} -
- {title} + <> +
+
+ {onBack && !hideTopBarBackButton ? ( + + ) : ( +
+ )} +
+ {title} +
+
{hideTopBarRightAction ? null : rightAction}
-
{rightAction}
-
- {isLoading ? ( -
- - {loadingLabel} -
- ) : ( -
{children}
- )} -
+ {isLoading ? ( +
+ + {loadingLabel} +
+ ) : ( +
{children}
+ )} +
+ {shouldRenderFloatingAction && typeof document !== 'undefined' + ? createPortal( +
+
+ {floatingAction} +
+
, + document.body + ) + : null} + ); } ); - diff --git a/src/pages/AiProvidersAmpcodeEditPage.tsx b/src/pages/AiProvidersAmpcodeEditPage.tsx index fc98a27..a90e54e 100644 --- a/src/pages/AiProvidersAmpcodeEditPage.tsx +++ b/src/pages/AiProvidersAmpcodeEditPage.tsx @@ -271,10 +271,28 @@ export function AiProvidersAmpcodeEditPage() { onBack={handleBack} backLabel={t('common.back')} backAriaLabel={t('common.back')} - rightAction={ - + hideTopBarBackButton + hideTopBarRightAction + floatingAction={ +
+ + +
} isLoading={loading} loadingLabel={t('common.loading')} diff --git a/src/pages/AiProvidersClaudeEditPage.tsx b/src/pages/AiProvidersClaudeEditPage.tsx index 5916ff4..7c73d3b 100644 --- a/src/pages/AiProvidersClaudeEditPage.tsx +++ b/src/pages/AiProvidersClaudeEditPage.tsx @@ -272,10 +272,28 @@ export function AiProvidersClaudeEditPage() { onBack={handleBack} backLabel={t('common.back')} backAriaLabel={t('common.back')} - rightAction={ - + hideTopBarBackButton + hideTopBarRightAction + floatingAction={ +
+ + +
} isLoading={loading} loadingLabel={t('common.loading')} diff --git a/src/pages/AiProvidersClaudeModelsPage.tsx b/src/pages/AiProvidersClaudeModelsPage.tsx index c6cb923..b26e9da 100644 --- a/src/pages/AiProvidersClaudeModelsPage.tsx +++ b/src/pages/AiProvidersClaudeModelsPage.tsx @@ -163,10 +163,27 @@ export function AiProvidersClaudeModelsPage() { onBack={handleBack} backLabel={t('common.back')} backAriaLabel={t('common.back')} - rightAction={ - + hideTopBarBackButton + hideTopBarRightAction + floatingAction={ +
+ + +
} isLoading={initialLoading} loadingLabel={t('common.loading')} diff --git a/src/pages/AiProvidersCodexEditPage.tsx b/src/pages/AiProvidersCodexEditPage.tsx index 43f4d2b..2fe2418 100644 --- a/src/pages/AiProvidersCodexEditPage.tsx +++ b/src/pages/AiProvidersCodexEditPage.tsx @@ -417,10 +417,28 @@ export function AiProvidersCodexEditPage() { onBack={handleBack} backLabel={t('common.back')} backAriaLabel={t('common.back')} - rightAction={ - + hideTopBarBackButton + hideTopBarRightAction + floatingAction={ +
+ + +
} isLoading={loading} loadingLabel={t('common.loading')} diff --git a/src/pages/AiProvidersEditLayout.module.scss b/src/pages/AiProvidersEditLayout.module.scss index 4166476..84d97dc 100644 --- a/src/pages/AiProvidersEditLayout.module.scss +++ b/src/pages/AiProvidersEditLayout.module.scss @@ -4,6 +4,20 @@ margin: 0 auto; } +.floatingActions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.floatingBackButton { + min-width: 82px; +} + +.floatingSaveButton { + min-width: 88px; +} + .upstreamApiKeyRow { display: flex; align-items: center; diff --git a/src/pages/AiProvidersGeminiEditPage.tsx b/src/pages/AiProvidersGeminiEditPage.tsx index 027acd5..95e40b1 100644 --- a/src/pages/AiProvidersGeminiEditPage.tsx +++ b/src/pages/AiProvidersGeminiEditPage.tsx @@ -411,10 +411,28 @@ export function AiProvidersGeminiEditPage() { onBack={handleBack} backLabel={t('common.back')} backAriaLabel={t('common.back')} - rightAction={ - + hideTopBarBackButton + hideTopBarRightAction + floatingAction={ +
+ + +
} isLoading={loading} loadingLabel={t('common.loading')} diff --git a/src/pages/AiProvidersOpenAIEditPage.tsx b/src/pages/AiProvidersOpenAIEditPage.tsx index 7302cf9..4bf9039 100644 --- a/src/pages/AiProvidersOpenAIEditPage.tsx +++ b/src/pages/AiProvidersOpenAIEditPage.tsx @@ -501,10 +501,28 @@ export function AiProvidersOpenAIEditPage() { onBack={handleBack} backLabel={t('common.back')} backAriaLabel={t('common.back')} - rightAction={ - + hideTopBarBackButton + hideTopBarRightAction + floatingAction={ +
+ + +
} isLoading={loading} loadingLabel={t('common.loading')} diff --git a/src/pages/AiProvidersOpenAIModelsPage.tsx b/src/pages/AiProvidersOpenAIModelsPage.tsx index 4ef43de..34cb38e 100644 --- a/src/pages/AiProvidersOpenAIModelsPage.tsx +++ b/src/pages/AiProvidersOpenAIModelsPage.tsx @@ -144,10 +144,27 @@ export function AiProvidersOpenAIModelsPage() { onBack={handleBack} backLabel={t('common.back')} backAriaLabel={t('common.back')} - rightAction={ - + hideTopBarBackButton + hideTopBarRightAction + floatingAction={ +
+ + +
} isLoading={initialLoading} loadingLabel={t('common.loading')} diff --git a/src/pages/AiProvidersVertexEditPage.tsx b/src/pages/AiProvidersVertexEditPage.tsx index 3c36ca7..90cb9ea 100644 --- a/src/pages/AiProvidersVertexEditPage.tsx +++ b/src/pages/AiProvidersVertexEditPage.tsx @@ -258,10 +258,28 @@ export function AiProvidersVertexEditPage() { onBack={handleBack} backLabel={t('common.back')} backAriaLabel={t('common.back')} - rightAction={ - + hideTopBarBackButton + hideTopBarRightAction + floatingAction={ +
+ + +
} isLoading={loading} loadingLabel={t('common.loading')} diff --git a/src/pages/LogsPage.module.scss b/src/pages/LogsPage.module.scss index 7c54d7a..a88bccd 100644 --- a/src/pages/LogsPage.module.scss +++ b/src/pages/LogsPage.module.scss @@ -612,6 +612,13 @@ color: var(--text-primary); } +.traceCandidatesHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + .traceInfoGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/pages/LogsPage.tsx b/src/pages/LogsPage.tsx index 2c00887..86dfa12 100644 --- a/src/pages/LogsPage.tsx +++ b/src/pages/LogsPage.tsx @@ -947,7 +947,20 @@ export function LogsPage() {
-

{t('logs.trace_candidates_title')}

+
+

{t('logs.trace_candidates_title')}

+ +
{trace.traceLoading ? (
{t('logs.trace_loading')}
) : trace.traceError ? ( diff --git a/src/pages/hooks/useTraceResolver.ts b/src/pages/hooks/useTraceResolver.ts index 6b74191..6b2f90b 100644 --- a/src/pages/hooks/useTraceResolver.ts +++ b/src/pages/hooks/useTraceResolver.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { authFilesApi } from '@/services/api/authFiles'; -import { usageApi } from '@/services/api/usage'; +import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores'; import type { AuthFileItem, Config } from '@/types'; import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo'; import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver'; @@ -21,7 +21,7 @@ export type TraceCandidate = { timeDeltaMs: number | null; }; -const TRACE_USAGE_CACHE_MS = 60 * 1000; +const TRACE_AUTH_CACHE_MS = 60 * 1000; const TRACE_MATCH_STRONG_WINDOW_MS = 3 * 1000; const TRACE_MATCH_WINDOW_MS = 10 * 1000; const TRACE_MATCH_MAX_WINDOW_MS = 30 * 1000; @@ -138,6 +138,7 @@ interface UseTraceResolverReturn { traceCandidates: TraceCandidate[]; resolveTraceSourceInfo: (sourceRaw: string, authIndex: unknown) => SourceInfo; loadTraceUsageDetails: () => Promise; + refreshTraceUsageDetails: () => Promise; openTraceModal: (line: ParsedLogLine) => void; closeTraceModal: () => void; } @@ -145,25 +146,30 @@ interface UseTraceResolverReturn { export function useTraceResolver(options: UseTraceResolverOptions): UseTraceResolverReturn { const { traceScopeKey, connectionStatus, config, requestLogDownloading } = options; const { t } = useTranslation(); + const usageSnapshot = useUsageStatsStore((state) => state.usage); + const usageScopeKey = useUsageStatsStore((state) => state.scopeKey); + const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats); const [traceLogLine, setTraceLogLine] = useState(null); - const [traceUsageDetails, setTraceUsageDetails] = useState([]); const [traceAuthFileMap, setTraceAuthFileMap] = useState>(new Map()); const [traceLoading, setTraceLoading] = useState(false); const [traceError, setTraceError] = useState(''); - const traceUsageLoadedAtRef = useRef(0); const traceAuthLoadedAtRef = useRef(0); const traceScopeKeyRef = useRef(''); + const scopedUsageSnapshot = usageScopeKey === traceScopeKey ? usageSnapshot : null; + const traceUsageDetails = useMemo( + () => collectUsageDetailsWithEndpoint(scopedUsageSnapshot), + [scopedUsageSnapshot] + ); + const traceSourceInfoMap = useMemo(() => buildSourceInfoMap(config ?? {}), [config]); - const loadTraceUsageDetails = useCallback(async () => { + const loadTraceUsageDetailsInternal = useCallback(async (forceUsage: boolean) => { if (traceScopeKeyRef.current !== traceScopeKey) { traceScopeKeyRef.current = traceScopeKey; - traceUsageLoadedAtRef.current = 0; traceAuthLoadedAtRef.current = 0; - setTraceUsageDetails([]); setTraceAuthFileMap(new Map()); setTraceError(''); } @@ -171,27 +177,20 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso if (traceLoading) return; const now = Date.now(); - const usageFresh = - traceUsageLoadedAtRef.current > 0 && now - traceUsageLoadedAtRef.current < TRACE_USAGE_CACHE_MS; const authFresh = - traceAuthLoadedAtRef.current > 0 && now - traceAuthLoadedAtRef.current < TRACE_USAGE_CACHE_MS; - if (usageFresh && authFresh) return; + traceAuthLoadedAtRef.current > 0 && now - traceAuthLoadedAtRef.current < TRACE_AUTH_CACHE_MS; setTraceLoading(true); setTraceError(''); try { - const [usageResponse, authFilesResponse] = await Promise.all([ - usageFresh ? Promise.resolve(null) : usageApi.getUsage(), + const [, authFilesResponse] = await Promise.all([ + loadUsageStats({ + force: forceUsage, + staleTimeMs: USAGE_STATS_STALE_TIME_MS + }), authFresh ? Promise.resolve(null) : authFilesApi.list().catch(() => null) ]); - if (usageResponse !== null) { - const usageData = usageResponse?.usage ?? usageResponse; - const details = collectUsageDetailsWithEndpoint(usageData); - setTraceUsageDetails(details); - traceUsageLoadedAtRef.current = now; - } - if (authFilesResponse !== null) { const files = Array.isArray(authFilesResponse) ? authFilesResponse @@ -207,7 +206,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso }); }); setTraceAuthFileMap(map); - traceAuthLoadedAtRef.current = now; + traceAuthLoadedAtRef.current = Date.now(); } } } catch (err: unknown) { @@ -215,14 +214,20 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso } finally { setTraceLoading(false); } - }, [t, traceLoading, traceScopeKey]); + }, [loadUsageStats, t, traceLoading, traceScopeKey]); + + const loadTraceUsageDetails = useCallback(async () => { + await loadTraceUsageDetailsInternal(false); + }, [loadTraceUsageDetailsInternal]); + + const refreshTraceUsageDetails = useCallback(async () => { + await loadTraceUsageDetailsInternal(true); + }, [loadTraceUsageDetailsInternal]); useEffect(() => { if (connectionStatus === 'connected') { traceScopeKeyRef.current = traceScopeKey; - traceUsageLoadedAtRef.current = 0; traceAuthLoadedAtRef.current = 0; - setTraceUsageDetails([]); setTraceAuthFileMap(new Map()); setTraceLoading(false); setTraceError(''); @@ -271,6 +276,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso traceCandidates, resolveTraceSourceInfo, loadTraceUsageDetails, + refreshTraceUsageDetails, openTraceModal, closeTraceModal };