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 (
{label}
+ {elapsed > 0 ? {elapsed}s : null}
{retryActive && retryState?.errorMessage ? (
{retryState.errorMessage.slice(0, 80)}
) : null}
diff --git a/frontend/src/context/ChatContext.tsx b/frontend/src/context/ChatContext.tsx
index b83879f..16b1ad0 100644
--- a/frontend/src/context/ChatContext.tsx
+++ b/frontend/src/context/ChatContext.tsx
@@ -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;
extensionStatuses: Record;
extensionDialog: ExtensionDialogState | null;
@@ -511,6 +513,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const [pendingFollowUp, setPendingFollowUp] = useState([]);
const [retryState, setRetryState] = useState(null);
const [isCompacting, setIsCompacting] = useState(false);
+ const [runPhase, setRunPhase] = useState(null);
const [extensionWidgets, setExtensionWidgets] = useState>({});
const [extensionStatuses, setExtensionStatuses] = useState>({});
const [extensionDialog, setExtensionDialog] = useState(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,
diff --git a/frontend/src/routes/ChatPage.tsx b/frontend/src/routes/ChatPage.tsx
index 9f5740b..fbb30ba 100644
--- a/frontend/src/routes/ChatPage.tsx
+++ b/frontend/src/routes/ChatPage.tsx
@@ -13,6 +13,7 @@ export function ChatPage() {
pendingFollowUp,
isStreaming,
isCompacting,
+ runPhase,
retryState,
extensionWidgets,
extensionDialog,
@@ -31,6 +32,7 @@ export function ChatPage() {
void abortStream()}
onAbortRetry={() => void abortRetry()}
diff --git a/frontend/src/types/runPhase.ts b/frontend/src/types/runPhase.ts
new file mode 100644
index 0000000..d777e63
--- /dev/null
+++ b/frontend/src/types/runPhase.ts
@@ -0,0 +1,13 @@
+export type RunPhase =
+ | "starting"
+ | "thinking"
+ | "writing"
+ | "tool"
+ | "bash"
+ | "processing";
+
+export interface RunPhaseState {
+ phase: RunPhase;
+ detail?: string;
+ startedAt: number;
+}
diff --git a/frontend/src/utils/runPhase.ts b/frontend/src/utils/runPhase.ts
new file mode 100644
index 0000000..ab68e22
--- /dev/null
+++ b/frontend/src/utils/runPhase.ts
@@ -0,0 +1,41 @@
+import type { RunPhase } from "../types/runPhase";
+
+const TOOL_LABELS: Record = {
+ 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 "生成中…";
+ }
+}