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 {
|
.retryMeta {
|
||||||
color: var(--text-tertiary, #9ca3af);
|
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 { RetryState } from "../../types/events";
|
||||||
|
import type { RunPhaseState } from "../../types/runPhase";
|
||||||
|
import { formatRunPhaseLabel } from "../../utils/runPhase";
|
||||||
import styles from "./RunStatusBar.module.css";
|
import styles from "./RunStatusBar.module.css";
|
||||||
|
|
||||||
interface RunStatusBarProps {
|
interface RunStatusBarProps {
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
isCompacting: boolean;
|
isCompacting: boolean;
|
||||||
|
runPhase: RunPhaseState | null;
|
||||||
retryState: RetryState | null;
|
retryState: RetryState | null;
|
||||||
onAbort: () => void;
|
onAbort: () => void;
|
||||||
onAbortRetry: () => 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({
|
export function RunStatusBar({
|
||||||
isStreaming,
|
isStreaming,
|
||||||
isCompacting,
|
isCompacting,
|
||||||
|
runPhase,
|
||||||
retryState,
|
retryState,
|
||||||
onAbort,
|
onAbort,
|
||||||
onAbortRetry,
|
onAbortRetry,
|
||||||
}: RunStatusBarProps) {
|
}: RunStatusBarProps) {
|
||||||
const retryActive = retryState?.active;
|
const retryActive = Boolean(retryState?.active);
|
||||||
if (!isStreaming && !isCompacting && !retryActive) return null;
|
const visible = isStreaming || isCompacting || retryActive;
|
||||||
|
|
||||||
let label = "";
|
let label = "";
|
||||||
|
let startedAt: number | undefined;
|
||||||
|
|
||||||
if (retryActive) {
|
if (retryActive) {
|
||||||
const attempt = retryState?.attempt ?? 1;
|
const attempt = retryState?.attempt ?? 1;
|
||||||
const max = retryState?.maxAttempts ?? attempt;
|
const max = retryState?.maxAttempts ?? attempt;
|
||||||
label = `自动重试中 (${attempt}/${max})`;
|
label = `自动重试中 (${attempt}/${max})`;
|
||||||
|
startedAt = retryState?.startedAt;
|
||||||
} else if (isCompacting) {
|
} else if (isCompacting) {
|
||||||
label = "整理上下文中…";
|
label = "整理上下文中…";
|
||||||
} else {
|
} else if (runPhase) {
|
||||||
|
label = formatRunPhaseLabel(runPhase.phase, runPhase.detail);
|
||||||
|
startedAt = runPhase.startedAt;
|
||||||
|
} else if (isStreaming) {
|
||||||
label = "生成中…";
|
label = "生成中…";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elapsed = useElapsedSeconds(startedAt, visible);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.bar}>
|
<div className={styles.bar}>
|
||||||
<div className={styles.statusText}>
|
<div className={styles.statusText}>
|
||||||
<span className={styles.dot} aria-hidden="true" />
|
<span className={styles.dot} aria-hidden="true" />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
{elapsed > 0 ? <span className={styles.elapsed}>{elapsed}s</span> : null}
|
||||||
{retryActive && retryState?.errorMessage ? (
|
{retryActive && retryState?.errorMessage ? (
|
||||||
<span className={styles.retryMeta}>{retryState.errorMessage.slice(0, 80)}</span>
|
<span className={styles.retryMeta}>{retryState.errorMessage.slice(0, 80)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import * as sessionsApi from "../api/sessions";
|
|||||||
import { apiUrl } from "../api/base";
|
import { apiUrl } from "../api/base";
|
||||||
import { useBootstrap } from "./BootstrapContext";
|
import { useBootstrap } from "./BootstrapContext";
|
||||||
import type { ExtensionDialogState, RetryState, SseEvent } from "../types/events";
|
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 { ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel, ToolCallBlock } from "../types/message";
|
||||||
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
||||||
import { nextMessageId, syncViewportHeight, stripAnsi } from "../utils/format";
|
import { nextMessageId, syncViewportHeight, stripAnsi } from "../utils/format";
|
||||||
@@ -54,6 +55,7 @@ interface ChatContextValue {
|
|||||||
pendingFollowUp: string[];
|
pendingFollowUp: string[];
|
||||||
retryState: RetryState | null;
|
retryState: RetryState | null;
|
||||||
isCompacting: boolean;
|
isCompacting: boolean;
|
||||||
|
runPhase: RunPhaseState | null;
|
||||||
extensionWidgets: Record<string, string[]>;
|
extensionWidgets: Record<string, string[]>;
|
||||||
extensionStatuses: Record<string, string>;
|
extensionStatuses: Record<string, string>;
|
||||||
extensionDialog: ExtensionDialogState | null;
|
extensionDialog: ExtensionDialogState | null;
|
||||||
@@ -511,6 +513,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
const [pendingFollowUp, setPendingFollowUp] = useState<string[]>([]);
|
const [pendingFollowUp, setPendingFollowUp] = useState<string[]>([]);
|
||||||
const [retryState, setRetryState] = useState<RetryState | null>(null);
|
const [retryState, setRetryState] = useState<RetryState | null>(null);
|
||||||
const [isCompacting, setIsCompacting] = useState(false);
|
const [isCompacting, setIsCompacting] = useState(false);
|
||||||
|
const [runPhase, setRunPhase] = useState<RunPhaseState | null>(null);
|
||||||
const [extensionWidgets, setExtensionWidgets] = useState<Record<string, string[]>>({});
|
const [extensionWidgets, setExtensionWidgets] = useState<Record<string, string[]>>({});
|
||||||
const [extensionStatuses, setExtensionStatuses] = useState<Record<string, string>>({});
|
const [extensionStatuses, setExtensionStatuses] = useState<Record<string, string>>({});
|
||||||
const [extensionDialog, setExtensionDialog] = useState<ExtensionDialogState | null>(null);
|
const [extensionDialog, setExtensionDialog] = useState<ExtensionDialogState | null>(null);
|
||||||
@@ -564,11 +567,23 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
[sessionState?.model],
|
[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(() => {
|
const resetStreamingState = useCallback(() => {
|
||||||
streamingAssistantId.current = null;
|
streamingAssistantId.current = null;
|
||||||
streamingThinkingId.current = null;
|
streamingThinkingId.current = null;
|
||||||
optimisticUserIdRef.current = null;
|
optimisticUserIdRef.current = null;
|
||||||
}, []);
|
clearRunPhase();
|
||||||
|
}, [clearRunPhase]);
|
||||||
|
|
||||||
const invalidateTurnEvents = useCallback(() => {
|
const invalidateTurnEvents = useCallback(() => {
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
@@ -1521,6 +1536,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
setConnected(true);
|
setConnected(true);
|
||||||
streamingAssistantId.current = null;
|
streamingAssistantId.current = null;
|
||||||
streamingThinkingId.current = null;
|
streamingThinkingId.current = null;
|
||||||
|
updateRunPhase("starting");
|
||||||
scheduleSessionDashboardRefresh();
|
scheduleSessionDashboardRefresh();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -1577,6 +1593,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
if (m.role === "assistant") {
|
if (m.role === "assistant") {
|
||||||
const thinking = extractThinking(m.content);
|
const thinking = extractThinking(m.content);
|
||||||
const assistantText = extractContent(m.content);
|
const assistantText = extractContent(m.content);
|
||||||
|
if (thinking.length > 0) {
|
||||||
|
updateRunPhase("thinking");
|
||||||
|
} else if (assistantText.length > 0) {
|
||||||
|
updateRunPhase("writing");
|
||||||
|
}
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
applyAssistantMessageUpdate(
|
applyAssistantMessageUpdate(
|
||||||
prev,
|
prev,
|
||||||
@@ -1596,6 +1617,13 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
const thinking = extractThinking(m.content);
|
const thinking = extractThinking(m.content);
|
||||||
const assistantText = extractContent(m.content);
|
const assistantText = extractContent(m.content);
|
||||||
const toolCalls = getToolCalls(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) =>
|
setMessages((prev) =>
|
||||||
finalizeAssistantMessageEnd(
|
finalizeAssistantMessageEnd(
|
||||||
prev,
|
prev,
|
||||||
@@ -1615,6 +1643,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
case "tool_execution_start": {
|
case "tool_execution_start": {
|
||||||
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
|
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
|
||||||
|
updateRunPhase("tool", ev.toolName);
|
||||||
const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`;
|
const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`;
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const existingIdx = tid
|
const existingIdx = tid
|
||||||
@@ -1640,6 +1669,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
case "tool_execution_update":
|
case "tool_execution_update":
|
||||||
case "tool_execution_end": {
|
case "tool_execution_end": {
|
||||||
|
updateRunPhase("tool", ev.toolName);
|
||||||
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
|
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
|
||||||
const isError = ev.type === "tool_execution_end" ? ev.isError : undefined;
|
const isError = ev.type === "tool_execution_end" ? ev.isError : undefined;
|
||||||
const full = buildToolCallUpdateText(ev, isError);
|
const full = buildToolCallUpdateText(ev, isError);
|
||||||
@@ -1729,6 +1759,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "bash_update": {
|
case "bash_update": {
|
||||||
|
updateRunPhase("bash");
|
||||||
const bashId = activeBashMsgIdRef.current;
|
const bashId = activeBashMsgIdRef.current;
|
||||||
if (!bashId) break;
|
if (!bashId) break;
|
||||||
const output = typeof ev.partialOutput === "string" ? ev.partialOutput : "";
|
const output = typeof ev.partialOutput === "string" ? ev.partialOutput : "";
|
||||||
@@ -1746,6 +1777,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
fetchSessionStateFull,
|
fetchSessionStateFull,
|
||||||
addSystemMessage,
|
addSystemMessage,
|
||||||
resetStreamingState,
|
resetStreamingState,
|
||||||
|
updateRunPhase,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1924,6 +1956,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
pendingFollowUp,
|
pendingFollowUp,
|
||||||
retryState,
|
retryState,
|
||||||
isCompacting,
|
isCompacting,
|
||||||
|
runPhase,
|
||||||
extensionWidgets,
|
extensionWidgets,
|
||||||
extensionStatuses,
|
extensionStatuses,
|
||||||
extensionDialog,
|
extensionDialog,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function ChatPage() {
|
|||||||
pendingFollowUp,
|
pendingFollowUp,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
isCompacting,
|
isCompacting,
|
||||||
|
runPhase,
|
||||||
retryState,
|
retryState,
|
||||||
extensionWidgets,
|
extensionWidgets,
|
||||||
extensionDialog,
|
extensionDialog,
|
||||||
@@ -31,6 +32,7 @@ export function ChatPage() {
|
|||||||
<RunStatusBar
|
<RunStatusBar
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
isCompacting={isCompacting}
|
isCompacting={isCompacting}
|
||||||
|
runPhase={runPhase}
|
||||||
retryState={retryState}
|
retryState={retryState}
|
||||||
onAbort={() => void abortStream()}
|
onAbort={() => void abortStream()}
|
||||||
onAbortRetry={() => void abortRetry()}
|
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