feat: show granular run status and elapsed time in chat footer
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -45,3 +45,8 @@
|
||||
.retryMeta {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
}
|
||||
|
||||
.elapsed {
|
||||
color: var(--text-tertiary, #9ca3af);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}
|
||||
|
||||
13
frontend/src/types/runPhase.ts
Normal file
13
frontend/src/types/runPhase.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type RunPhase =
|
||||
| "starting"
|
||||
| "thinking"
|
||||
| "writing"
|
||||
| "tool"
|
||||
| "bash"
|
||||
| "processing";
|
||||
|
||||
export interface RunPhaseState {
|
||||
phase: RunPhase;
|
||||
detail?: string;
|
||||
startedAt: number;
|
||||
}
|
||||
41
frontend/src/utils/runPhase.ts
Normal file
41
frontend/src/utils/runPhase.ts
Normal 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 "生成中…";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user