feat: show granular run status and elapsed time in chat footer

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-21 18:06:59 +08:00
parent cb2fe96a6e
commit 2742e19a2e
6 changed files with 132 additions and 4 deletions

View File

@@ -45,3 +45,8 @@
.retryMeta {
color: var(--text-tertiary, #9ca3af);
}
.elapsed {
color: var(--text-tertiary, #9ca3af);
font-variant-numeric: tabular-nums;
}

View File

@@ -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 (
<div className={styles.bar}>
<div className={styles.statusText}>
<span className={styles.dot} aria-hidden="true" />
<span>{label}</span>
{elapsed > 0 ? <span className={styles.elapsed}>{elapsed}s</span> : null}
{retryActive && retryState?.errorMessage ? (
<span className={styles.retryMeta}>{retryState.errorMessage.slice(0, 80)}</span>
) : null}

View File

@@ -14,6 +14,7 @@ import * as sessionsApi from "../api/sessions";
import { apiUrl } from "../api/base";
import { useBootstrap } from "./BootstrapContext";
import type { ExtensionDialogState, RetryState, SseEvent } from "../types/events";
import type { RunPhase, RunPhaseState } from "../types/runPhase";
import type { ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel, ToolCallBlock } from "../types/message";
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
import { nextMessageId, syncViewportHeight, stripAnsi } from "../utils/format";
@@ -54,6 +55,7 @@ interface ChatContextValue {
pendingFollowUp: string[];
retryState: RetryState | null;
isCompacting: boolean;
runPhase: RunPhaseState | null;
extensionWidgets: Record<string, string[]>;
extensionStatuses: Record<string, string>;
extensionDialog: ExtensionDialogState | null;
@@ -511,6 +513,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const [pendingFollowUp, setPendingFollowUp] = useState<string[]>([]);
const [retryState, setRetryState] = useState<RetryState | null>(null);
const [isCompacting, setIsCompacting] = useState(false);
const [runPhase, setRunPhase] = useState<RunPhaseState | null>(null);
const [extensionWidgets, setExtensionWidgets] = useState<Record<string, string[]>>({});
const [extensionStatuses, setExtensionStatuses] = useState<Record<string, string>>({});
const [extensionDialog, setExtensionDialog] = useState<ExtensionDialogState | null>(null);
@@ -564,11 +567,23 @@ export function ChatProvider({ children }: { children: ReactNode }) {
[sessionState?.model],
);
const clearRunPhase = useCallback(() => {
setRunPhase(null);
}, []);
const updateRunPhase = useCallback((phase: RunPhase, detail?: string) => {
setRunPhase((prev) => {
if (prev?.phase === phase && prev?.detail === detail) return prev;
return { phase, detail, startedAt: Date.now() };
});
}, []);
const resetStreamingState = useCallback(() => {
streamingAssistantId.current = null;
streamingThinkingId.current = null;
optimisticUserIdRef.current = null;
}, []);
clearRunPhase();
}, [clearRunPhase]);
const invalidateTurnEvents = useCallback(() => {
resetStreamingState();
@@ -1521,6 +1536,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
setConnected(true);
streamingAssistantId.current = null;
streamingThinkingId.current = null;
updateRunPhase("starting");
scheduleSessionDashboardRefresh();
break;
@@ -1577,6 +1593,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (m.role === "assistant") {
const thinking = extractThinking(m.content);
const assistantText = extractContent(m.content);
if (thinking.length > 0) {
updateRunPhase("thinking");
} else if (assistantText.length > 0) {
updateRunPhase("writing");
}
setMessages((prev) =>
applyAssistantMessageUpdate(
prev,
@@ -1596,6 +1617,13 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const thinking = extractThinking(m.content);
const assistantText = extractContent(m.content);
const toolCalls = getToolCalls(m.content);
if (toolCalls.length > 0) {
updateRunPhase("processing");
} else if (thinking.length > 0 && assistantText.length === 0) {
updateRunPhase("thinking");
} else if (assistantText.length > 0) {
updateRunPhase("writing");
}
setMessages((prev) =>
finalizeAssistantMessageEnd(
prev,
@@ -1615,6 +1643,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
case "tool_execution_start": {
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
updateRunPhase("tool", ev.toolName);
const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`;
setMessages((prev) => {
const existingIdx = tid
@@ -1640,6 +1669,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
case "tool_execution_update":
case "tool_execution_end": {
updateRunPhase("tool", ev.toolName);
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
const isError = ev.type === "tool_execution_end" ? ev.isError : undefined;
const full = buildToolCallUpdateText(ev, isError);
@@ -1729,6 +1759,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
break;
case "bash_update": {
updateRunPhase("bash");
const bashId = activeBashMsgIdRef.current;
if (!bashId) break;
const output = typeof ev.partialOutput === "string" ? ev.partialOutput : "";
@@ -1746,6 +1777,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
fetchSessionStateFull,
addSystemMessage,
resetStreamingState,
updateRunPhase,
],
);
@@ -1924,6 +1956,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
pendingFollowUp,
retryState,
isCompacting,
runPhase,
extensionWidgets,
extensionStatuses,
extensionDialog,

View File

@@ -13,6 +13,7 @@ export function ChatPage() {
pendingFollowUp,
isStreaming,
isCompacting,
runPhase,
retryState,
extensionWidgets,
extensionDialog,
@@ -31,6 +32,7 @@ export function ChatPage() {
<RunStatusBar
isStreaming={isStreaming}
isCompacting={isCompacting}
runPhase={runPhase}
retryState={retryState}
onAbort={() => void abortStream()}
onAbortRetry={() => void abortRetry()}

View File

@@ -0,0 +1,13 @@
export type RunPhase =
| "starting"
| "thinking"
| "writing"
| "tool"
| "bash"
| "processing";
export interface RunPhaseState {
phase: RunPhase;
detail?: string;
startedAt: number;
}

View File

@@ -0,0 +1,41 @@
import type { RunPhase } from "../types/runPhase";
const TOOL_LABELS: Record<string, string> = {
Read: "读取文件",
Write: "写入文件",
Edit: "编辑文件",
Grep: "搜索代码",
Glob: "查找文件",
Shell: "执行命令",
Task: "启动子任务",
WebSearch: "搜索网页",
WebFetch: "获取网页",
SemanticSearch: "语义搜索",
Delete: "删除文件",
AskQuestion: "等待确认",
};
function formatToolDetail(name: string): string {
const trimmed = name.trim();
if (!trimmed) return "工具";
return TOOL_LABELS[trimmed] ?? trimmed;
}
export function formatRunPhaseLabel(phase: RunPhase, detail?: string): string {
switch (phase) {
case "starting":
return "等待模型响应…";
case "thinking":
return "思考中…";
case "writing":
return "撰写回复中…";
case "tool":
return `调用 ${formatToolDetail(detail ?? "工具")}`;
case "bash":
return "运行命令…";
case "processing":
return "处理结果中…";
default:
return "生成中…";
}
}