diff --git a/frontend/src/components/chat/RunStatusBar.module.css b/frontend/src/components/chat/RunStatusBar.module.css index e5c50a6..d406f88 100644 --- a/frontend/src/components/chat/RunStatusBar.module.css +++ b/frontend/src/components/chat/RunStatusBar.module.css @@ -45,3 +45,8 @@ .retryMeta { color: var(--text-tertiary, #9ca3af); } + +.elapsed { + color: var(--text-tertiary, #9ca3af); + font-variant-numeric: tabular-nums; +} diff --git a/frontend/src/components/chat/RunStatusBar.tsx b/frontend/src/components/chat/RunStatusBar.tsx index 49f5096..67acfd6 100644 --- a/frontend/src/components/chat/RunStatusBar.tsx +++ b/frontend/src/components/chat/RunStatusBar.tsx @@ -1,40 +1,74 @@ +import { useEffect, useState } from "react"; import type { RetryState } from "../../types/events"; +import type { RunPhaseState } from "../../types/runPhase"; +import { formatRunPhaseLabel } from "../../utils/runPhase"; import styles from "./RunStatusBar.module.css"; interface RunStatusBarProps { isStreaming: boolean; isCompacting: boolean; + runPhase: RunPhaseState | null; retryState: RetryState | null; onAbort: () => void; onAbortRetry: () => void; } +function useElapsedSeconds(startedAt: number | undefined, active: boolean): number { + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + if (!active || startedAt == null) { + setElapsed(0); + return; + } + + const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))); + tick(); + const id = window.setInterval(tick, 1000); + return () => window.clearInterval(id); + }, [active, startedAt]); + + return elapsed; +} + export function RunStatusBar({ isStreaming, isCompacting, + runPhase, retryState, onAbort, onAbortRetry, }: RunStatusBarProps) { - const retryActive = retryState?.active; - if (!isStreaming && !isCompacting && !retryActive) return null; + const retryActive = Boolean(retryState?.active); + const visible = isStreaming || isCompacting || retryActive; let label = ""; + let startedAt: number | undefined; + if (retryActive) { const attempt = retryState?.attempt ?? 1; const max = retryState?.maxAttempts ?? attempt; label = `自动重试中 (${attempt}/${max})`; + startedAt = retryState?.startedAt; } else if (isCompacting) { label = "整理上下文中…"; - } else { + } else if (runPhase) { + label = formatRunPhaseLabel(runPhase.phase, runPhase.detail); + startedAt = runPhase.startedAt; + } else if (isStreaming) { label = "生成中…"; } + const elapsed = useElapsedSeconds(startedAt, visible); + + if (!visible) return null; + return (