From 243157aacd4440691df7d884991ae42962493e87 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 5 Jun 2026 22:56:52 +0800 Subject: [PATCH] fix: improve webui steer and markdown rendering --- .../conversation/ConversationPanel.test.ts | 11 ++-- .../conversation/ConversationPanel.tsx | 6 +- .../conversation/MessageTracePanel.test.tsx | 14 +---- .../conversation/MessageTracePanel.tsx | 9 ++- .../shared/MessageMarkdown.test.tsx | 12 ++-- .../src/components/shared/MessageMarkdown.tsx | 21 ++----- .../state/issue853-refresh-regression.test.ts | 7 ++- .../src/state/workbench-reducer.test.ts | 59 ++++++++++++++++++ .../src/state/workbench-reducer.ts | 9 +++ web/hwlab-cloud-web/src/state/workbench.ts | 60 ++++++++++++++----- 10 files changed, 143 insertions(+), 65 deletions(-) diff --git a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts index 86151fd3..28935038 100644 --- a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; -import { FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS, shouldAutoReplayTrace } from "./ConversationPanel"; +import { shouldAutoReplayTrace } from "./ConversationPanel"; import type { ChatMessage } from "../../types/domain"; const baseAgentMessage: ChatMessage = { @@ -34,14 +34,11 @@ test("conversation panel does not auto replay loaded, empty, or running traces", assert.equal(shouldAutoReplayTrace({ ...baseAgentMessage, runnerTrace: { ...baseAgentMessage.runnerTrace, fullTraceLoaded: true, events: [{ seq: 1, label: "assistant:message" }] } }), false); }); -test("conversation panel delays first-screen historical markdown upgrade past the LCP window", () => { - assert.ok(FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS > 3000); -}); - -test("conversation panel passes the first-screen markdown delay to historical message bodies", async () => { +test("conversation panel does not delay markdown rendering behind first-screen fallbacks", async () => { const source = await Bun.file(new URL("./ConversationPanel.tsx", import.meta.url)).text(); - assert.match(source, /MessageMarkdown upgradeDelayMs=\{firstScreenHistory \? FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS : undefined\}/u); + assert.doesNotMatch(source, /MessageMarkdown upgradeDelayMs=/u); + assert.doesNotMatch(source, /markdownUpgradeDelayMs/u); }); test("conversation panel asks trace panels to collapse after final response appears", async () => { diff --git a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx index 9fbe9e0d..39b98d97 100644 --- a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx @@ -127,7 +127,6 @@ export function shouldAutoReplayTrace(message: ChatMessage): boolean { } const HISTORICAL_TRACE_REPLAY_DELAY_MS = 4500; -export const FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS = 5200; function MessageCard({ message, firstScreenHistory, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; firstScreenHistory: boolean; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement { const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`; @@ -149,11 +148,10 @@ function MessageCard({ message, firstScreenHistory, codeAgentTimeoutMs, onCancel defaultOpen={message.status === "running"} storageKey={traceKey} collapseWhenFinalResponse={hasFinalResponse} - markdownUpgradeDelayMs={firstScreenHistory ? FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS : undefined} /> ) : null}
- {hasFinalResponse ? {message.text} : null} + {hasFinalResponse ? {message.text} : null}
- {message.text} + {message.text} { +test("message trace panel renders assistant markdown rows through the shared renderer", () => { const trace: RunnerTrace = { traceId: "trc_markdown_row", status: "completed", @@ -27,19 +27,11 @@ test("message trace panel marks assistant markdown rows for deferred shared rend assert.match(html, /data-body-format="markdown"/u); assert.match(html, /message-trace-markdown/u); - assert.match(html, /\| hwpodId \| node \|/u); - assert.match(html, /`hwpod-f103`/u); + assert.match(html, //u); + assert.match(html, /hwpod-f103<\/code>/u); assert.doesNotMatch(html, /
 {
-  const source = Bun.file(new URL("./MessageTracePanel.tsx", import.meta.url));
-  return source.text().then((text) => {
-    assert.match(text, /markdownUpgradeDelayMs\?: number/u);
-    assert.match(text, /upgradeDelayMs=\{markdownUpgradeDelayMs\}/u);
-  });
-});
-
 test("message trace panel exposes running status for live trace animation", () => {
   const trace: RunnerTrace = {
     traceId: "trc_running_row",
diff --git a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
index 5d2ac486..9f3da41f 100644
--- a/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
+++ b/web/hwlab-cloud-web/src/components/conversation/MessageTracePanel.tsx
@@ -12,10 +12,9 @@ interface MessageTracePanelProps {
   defaultOpen?: boolean;
   storageKey?: string;
   collapseWhenFinalResponse?: boolean;
-  markdownUpgradeDelayMs?: number;
 }
 
-export function MessageTracePanel({ trace, defaultOpen, storageKey, collapseWhenFinalResponse, markdownUpgradeDelayMs }: MessageTracePanelProps): ReactElement {
+export function MessageTracePanel({ trace, defaultOpen, storageKey, collapseWhenFinalResponse }: MessageTracePanelProps): ReactElement {
   const traceRunning = isRunningTrace(trace);
   const shouldCollapseForFinalResponse = collapseWhenFinalResponse === true && !traceRunning;
   const storedOpen = shouldCollapseForFinalResponse ? false : readStoredOpen(storageKey, defaultOpen ?? traceRunning);
@@ -82,7 +81,7 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey, collapseWhen
                   
                   {row.header}
                 
-                {row.body ?  : null}
+                {row.body ?  : null}
               
             ))}
           
@@ -92,11 +91,11 @@ export function MessageTracePanel({ trace, defaultOpen, storageKey, collapseWhen
   );
 }
 
-function TraceRowBody({ row, markdownUpgradeDelayMs }: { row: TraceEventRow; markdownUpgradeDelayMs?: number }): ReactElement | null {
+function TraceRowBody({ row }: { row: TraceEventRow }): ReactElement | null {
   if (!row.body) return null;
   if (row.bodyFormat === "markdown") {
     return (
-      
+      
         {row.body}
       
     );
diff --git a/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx b/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx
index 33cda92e..bcd0c4a0 100644
--- a/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx
+++ b/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.test.tsx
@@ -5,12 +5,13 @@ import { renderToStaticMarkup } from "react-dom/server";
 import { MarkdownRenderer } from "./MarkdownRenderer";
 import { MessageMarkdown, PlainMessageText } from "./MessageMarkdown";
 
-test("message markdown module keeps the third-party renderer lazy for first screen", async () => {
+test("message markdown module renders through the shared third-party renderer immediately", async () => {
   const source = await Bun.file(new URL("./MessageMarkdown.tsx", import.meta.url)).text();
 
   assert.doesNotMatch(source, /from "react-markdown"/u);
-  assert.match(source, /lazy\(\(\) => import\("\.\/MarkdownRenderer"\)/u);
-  assert.match(source, /upgradeDelayMs = 1800/u);
+  assert.doesNotMatch(source, /setTimeout/u);
+  assert.doesNotMatch(source, /PlainMessageText>\{children\}<\/PlainMessageText/u);
+  assert.match(source, /\{children\}<\/MarkdownRenderer>/u);
 });
 
 test("markdown renderer renders GFM tables and inline code through the shared third-party component", () => {
@@ -31,10 +32,11 @@ test("message markdown escapes raw HTML instead of injecting handwritten HTML",
 });
 
 test("message markdown preserves source newlines for final response display", () => {
-  const html = renderToStaticMarkup({"第一行\n第二行"});
+  const html = renderToStaticMarkup({"第一行\n\n第二行"});
 
   assert.match(html, /class="message-body"/u);
-  assert.match(html, /第一行\n第二行/u);
+  assert.match(html, /

第一行<\/p>/u); + assert.match(html, /

第二行<\/p>/u); }); test("plain first-screen fallback escapes raw HTML", () => { diff --git a/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.tsx b/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.tsx index ceab8a6d..12b72f17 100644 --- a/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.tsx +++ b/web/hwlab-cloud-web/src/components/shared/MessageMarkdown.tsx @@ -1,33 +1,20 @@ -import type { ReactElement, ReactNode } from "react"; -import { lazy, Suspense, useEffect, useState } from "react"; +import type { ReactElement } from "react"; +import { MarkdownRenderer } from "./MarkdownRenderer"; interface MessageMarkdownProps { children: string; className?: string; - upgradeDelayMs?: number; [key: `data-${string}`]: string | undefined; } -export function MessageMarkdown({ children, className = "message-body", upgradeDelayMs = 1800, ...props }: MessageMarkdownProps): ReactElement { +export function MessageMarkdown({ children, className = "message-body", ...props }: MessageMarkdownProps): ReactElement { return (

- {children}} /> + {children}
); } -const MarkdownRenderer = lazy(() => import("./MarkdownRenderer").then((module) => ({ default: module.MarkdownRenderer }))); - -function DeferredMarkdown({ source, upgradeDelayMs, fallback }: { source: string; upgradeDelayMs: number; fallback: ReactNode }): ReactElement { - const [ready, setReady] = useState(false); - useEffect(() => { - const id = window.setTimeout(() => setReady(true), Math.max(0, upgradeDelayMs)); - return () => window.clearTimeout(id); - }, [source, upgradeDelayMs]); - if (!ready) return <>{fallback}; - return {source}; -} - export function PlainMessageText({ children }: { children: string }): ReactElement { return

{children}

; } diff --git a/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts b/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts index d3c2b0fa..cd6e9d70 100644 --- a/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts +++ b/web/hwlab-cloud-web/src/state/issue853-refresh-regression.test.ts @@ -8,20 +8,23 @@ const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") test("issue 853 persists submitted user and running agent messages before long Code Agent polling", () => { const source = fs.readFileSync(path.join(srcRoot, "state/workbench.ts"), "utf8"); + const visibleMessagesIndex = source.indexOf("const visibleMessages =", source.indexOf("const pendingWithRetry")); const pendingIndex = source.indexOf('dispatch({ type: "message:pending"'); const persistIndex = source.indexOf("let currentWorkspace = await persistConversation", pendingIndex); const routeIndex = source.indexOf("const route = steerMode ? api.steerAgentMessage : api.sendAgentMessage", pendingIndex); + assert.ok(visibleMessagesIndex > 0); assert.ok(pendingIndex > 0); assert.ok(persistIndex > pendingIndex); assert.ok(routeIndex > persistIndex); - assert.match(source.slice(persistIndex, routeIndex), /messages: \[\.\.\.state\.messages, user, pendingWithRetry\]/u); + assert.match(source.slice(visibleMessagesIndex, persistIndex), /: \[\.\.\.state\.messages, user, pendingWithRetry\]/u); + assert.match(source.slice(persistIndex, routeIndex), /messages: visibleMessages/u); }); test("issue 853 submit failure persists the same trace before refresh", () => { const source = fs.readFileSync(path.join(srcRoot, "state/workbench.ts"), "utf8"); const failureIndex = source.indexOf('const traceSnapshot = await fetchTraceSnapshot(traceId, traceId, Math.max(8000, state.codeAgentTimeoutMs), undefined, activeProjectId);'); const failedWithTraceIndex = source.indexOf('const failedWithTrace = runnerTrace ? { ...failed, runnerTrace } : failed;', failureIndex); - const persistIndex = source.indexOf('messages: [...state.messages, user, failedWithTrace]', failedWithTraceIndex); + const persistIndex = source.indexOf('messages: replaceMessage(visibleMessages, activePending.id, failedWithTrace)', failedWithTraceIndex); assert.ok(failureIndex > 0); assert.ok(failedWithTraceIndex > failureIndex); assert.ok(persistIndex > failedWithTraceIndex); diff --git a/web/hwlab-cloud-web/src/state/workbench-reducer.test.ts b/web/hwlab-cloud-web/src/state/workbench-reducer.test.ts index 26dfbcaa..2dce00a5 100644 --- a/web/hwlab-cloud-web/src/state/workbench-reducer.test.ts +++ b/web/hwlab-cloud-web/src/state/workbench-reducer.test.ts @@ -60,6 +60,65 @@ test("conversation selection scopes chatPending to the selected session messages assert.equal(composerFromState(selected, "cnv_done").submitMode, "turn"); }); +test("steer inserts the user guidance before the running agent message", () => { + const running = agentMessage({ conversationId: "cnv_active", traceId: "trc_active", status: "running" }); + const state = baseState({ + workspace: workspace("cnv_active", "trc_active"), + messages: [agentMessage({ conversationId: "cnv_active", traceId: "trc_done", status: "completed" }), running], + chatPending: true + }); + const steer: ChatMessage = { + id: "msg_steer", + role: "user", + title: "用户引导", + text: "补充 steer", + status: "sent", + createdAt: "2026-06-05T00:00:00.000Z", + traceId: "trc_steer_active", + conversationId: "cnv_active", + sessionId: "ses_active", + threadId: "thread_active" + }; + + const next = workbenchReducer(state, { + type: "message:steer", + user: steer, + targetMessageId: running.id, + request: { traceId: "trc_active", conversationId: "cnv_active", sessionId: "ses_active", threadId: "thread_active" } + }); + + assert.deepEqual(next.messages.map((message) => message.id), ["msg_trc_done", "msg_steer", "msg_trc_active"]); + assert.equal(next.currentRequest?.traceId, "trc_active"); + assert.equal(next.messages.filter((message) => message.role === "agent" && message.traceId === "trc_active").length, 1); +}); + +test("pending still appends a user and agent placeholder when no running card is visible", () => { + const state = baseState({ workspace: workspace("cnv_active", "trc_active"), messages: [], chatPending: false }); + const user: ChatMessage = { + id: "msg_steer", + role: "user", + title: "用户引导", + text: "补充 steer", + status: "sent", + createdAt: "2026-06-05T00:00:00.000Z", + traceId: "trc_steer_active", + conversationId: "cnv_active", + sessionId: "ses_active", + threadId: "thread_active" + }; + const pending = agentMessage({ conversationId: "cnv_active", traceId: "trc_active", status: "running" }); + + const next = workbenchReducer(state, { + type: "message:pending", + user, + pending, + request: { traceId: "trc_active", conversationId: "cnv_active", sessionId: "ses_active", threadId: "thread_active" } + }); + + assert.deepEqual(next.messages.map((message) => message.id), ["msg_steer", "msg_trc_active"]); + assert.equal(next.currentRequest?.traceId, "trc_active"); +}); + test("conversation selection restores steer mode when switching back to a running session", () => { const state = baseState({ workspace: workspace("cnv_done", null), messages: [], chatPending: false }); diff --git a/web/hwlab-cloud-web/src/state/workbench-reducer.ts b/web/hwlab-cloud-web/src/state/workbench-reducer.ts index 928885a5..ed6d6e08 100644 --- a/web/hwlab-cloud-web/src/state/workbench-reducer.ts +++ b/web/hwlab-cloud-web/src/state/workbench-reducer.ts @@ -13,6 +13,7 @@ export type Action = | { type: "timeout:set"; codeAgentTimeoutMs: number } | { type: "gateway-timeout:set"; gatewayShellTimeoutMs: number } | { type: "message:pending"; user: ChatMessage; pending: ChatMessage; request: NonNullable } + | { type: "message:steer"; user: ChatMessage; targetMessageId: string; request: NonNullable } | { type: "message:replace"; messageId: string; message: ChatMessage } | { type: "message:trace-status"; messageId: string; status: string } | { type: "message:trace"; messageId: string; trace: NonNullable } @@ -34,6 +35,7 @@ export function workbenchReducer(state: WorkbenchState, action: Action): Workben case "timeout:set": return { ...state, codeAgentTimeoutMs: action.codeAgentTimeoutMs }; case "gateway-timeout:set": return { ...state, gatewayShellTimeoutMs: action.gatewayShellTimeoutMs }; case "message:pending": return { ...state, messages: [...state.messages, action.user, action.pending], chatPending: true, currentRequest: { ...action.request, status: "running" } }; + case "message:steer": return { ...state, messages: insertBeforeMessage(state.messages, action.targetMessageId, action.user), chatPending: true, currentRequest: { ...action.request, status: "running" } }; case "message:replace": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message) }; case "message:trace-status": { const updated = state.messages.map((message) => message.id === action.messageId ? { ...message, traceReplayStatus: action.status, updatedAt: new Date().toISOString() } : message); @@ -60,6 +62,13 @@ function replaceMessage(messages: ChatMessage[], messageId: string, replacement: return messages.map((message) => message.id === messageId ? replacement : message); } +function insertBeforeMessage(messages: ChatMessage[], messageId: string, inserted: ChatMessage): ChatMessage[] { + if (messages.some((message) => message.id === inserted.id)) return messages; + const index = messages.findIndex((message) => message.id === messageId); + if (index < 0) return [...messages, inserted]; + return [...messages.slice(0, index), inserted, ...messages.slice(index)]; +} + export function composerFromState(state: WorkbenchState, activeConversationId: string | null): ComposerState { const latestMessage = latestConversationMessage(state.messages, activeConversationId); const runningMessage = findRunningAgentMessage(state.messages, activeConversationId); diff --git a/web/hwlab-cloud-web/src/state/workbench.ts b/web/hwlab-cloud-web/src/state/workbench.ts index 18748245..15e0b176 100644 --- a/web/hwlab-cloud-web/src/state/workbench.ts +++ b/web/hwlab-cloud-web/src/state/workbench.ts @@ -222,16 +222,22 @@ export function useWorkbenchStore(enabled: boolean, projectId = WORKBENCH_PROJEC const value = text.trim(); if (!value) return; const steerMode = composer.submitMode === "steer"; - const traceId = steerMode && composer.targetTraceId ? composer.targetTraceId : nextProtocolId("trc"); + const targetTraceId = steerMode && composer.targetTraceId ? composer.targetTraceId : null; + const traceId = targetTraceId ?? nextProtocolId("trc"); const steerTraceId = steerMode ? nextProtocolId("trc_steer") : null; + const displayTraceId = steerTraceId ?? traceId; const conversationId = composer.conversationId ?? activeConversationId ?? nextProtocolId("cnv"); const sessionId = composer.sessionId ?? state.workspace?.selectedAgentSessionId ?? state.workspace?.workspace?.selectedAgentSessionId ?? null; const threadId = composer.threadId ?? state.workspace?.workspace?.threadId ?? null; - const user = makeMessage("user", value, "sent", { traceId, conversationId, sessionId, threadId }); + const user = makeMessage("user", value, "sent", { traceId: displayTraceId, conversationId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" }); const pending = makeMessage("agent", "正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent 处理中" }); const pendingWithRetry: ChatMessage = { ...pending, retryInput: value }; - dispatch({ type: "message:pending", user, pending: pendingWithRetry, request: { traceId, conversationId, sessionId, threadId } }); - let currentWorkspace = await persistConversation({ workspace: state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: [...state.messages, user, pendingWithRetry] }); + const existingActivePending = steerMode ? findActiveAgentMessage(state.messages, traceId, conversationId) : null; + const activePending = existingActivePending ?? pendingWithRetry; + const visibleMessages = steerMode && existingActivePending ? insertBeforeMessage(state.messages, existingActivePending.id, user) : [...state.messages, user, pendingWithRetry]; + if (steerMode && existingActivePending) dispatch({ type: "message:steer", user, targetMessageId: existingActivePending.id, request: { traceId, conversationId, sessionId, threadId } }); + else dispatch({ type: "message:pending", user, pending: pendingWithRetry, request: { traceId, conversationId, sessionId, threadId } }); + let currentWorkspace = await persistConversation({ workspace: state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: visibleMessages }); if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace }); const route = steerMode ? api.steerAgentMessage : api.sendAgentMessage; updateActivity(); @@ -248,13 +254,13 @@ export function useWorkbenchStore(enabled: boolean, projectId = WORKBENCH_PROJEC gatewayShellTimeoutMs: state.gatewayShellTimeoutMs, workspaceId: currentWorkspace?.workspaceId ?? state.workspace?.workspaceId, workspaceRevision: currentWorkspace?.revision ?? state.workspace?.revision, - targetTraceId: steerMode ? traceId : null, + targetTraceId, steerTraceId }, state.codeAgentTimeoutMs, submitActivityRef); const finalize = async (result: AgentChatResultResponse, availability: CodeAgentAvailability | null): Promise => { - const completed = messageFromAgentResponse(pending.id, pending, result); - dispatch({ type: "message:complete", messageId: pending.id, message: completed, availability: availability ?? result.availability ?? null }); - currentWorkspace = await persistConversation({ workspace: currentWorkspace ?? state.workspace, projectId: activeProjectId, conversationId, sessionId: completed.sessionId ?? sessionId, threadId: completed.threadId ?? threadId, messages: [...state.messages, user, completed] }); + const completed = messageFromAgentResponse(activePending.id, activePending, result); + dispatch({ type: "message:complete", messageId: activePending.id, message: completed, availability: availability ?? result.availability ?? null }); + currentWorkspace = await persistConversation({ workspace: currentWorkspace ?? state.workspace, projectId: activeProjectId, conversationId, sessionId: completed.sessionId ?? sessionId, threadId: completed.threadId ?? threadId, messages: replaceMessage(visibleMessages, activePending.id, completed) }); if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace }); const conversations = await api.conversations(activeProjectId); if (conversations.ok) dispatch({ type: "conversation:list", conversations: conversations.data?.conversations ?? [] }); @@ -273,7 +279,7 @@ export function useWorkbenchStore(enabled: boolean, projectId = WORKBENCH_PROJEC onActivity: () => updateActivity(), onSnapshot: (snapshot) => { lastSnapshot = snapshot; - dispatch({ type: "message:trace", messageId: pending.id, trace: snapshotToRunnerTrace(snapshot) }); + dispatch({ type: "message:trace", messageId: activePending.id, trace: snapshotToRunnerTrace(snapshot) }); }, onComplete: (result) => { terminalResult = result; @@ -282,8 +288,8 @@ export function useWorkbenchStore(enabled: boolean, projectId = WORKBENCH_PROJEC // HWLAB #802: persist fail too. The user refreshing should see // the frontend-reported failure, not a silent "running" state. const failed = makeMessage("agent", `Code Agent 在 ${state.codeAgentTimeoutMs}ms 无活动后中断(${error});可以继续 steer 或查看 trace。`, "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 超时" }); - dispatch({ type: "message:fail", messageId: pending.id, message: failed }); - void persistConversation({ workspace: currentWorkspace ?? state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: [...state.messages, user, failed] }) + dispatch({ type: "message:fail", messageId: activePending.id, message: failed }); + void persistConversation({ workspace: currentWorkspace ?? state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: replaceMessage(visibleMessages, activePending.id, failed) }) .then((workspace) => { if (workspace) dispatch({ type: "workspace:sync", workspace }); }); subscriptionController.abort(); } @@ -303,9 +309,9 @@ export function useWorkbenchStore(enabled: boolean, projectId = WORKBENCH_PROJEC const failed = makeMessage("agent", response.error ?? "Code Agent 请求失败", "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 请求失败" }); const runnerTrace = traceSnapshot ? snapshotToRunnerTrace(traceSnapshot) : null; const failedWithTrace = runnerTrace ? { ...failed, runnerTrace } : failed; - if (runnerTrace) dispatch({ type: "message:trace", messageId: pending.id, trace: runnerTrace }); - dispatch({ type: "message:fail", messageId: pending.id, message: failedWithTrace }); - currentWorkspace = await persistConversation({ workspace: currentWorkspace ?? state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: [...state.messages, user, failedWithTrace] }); + if (runnerTrace) dispatch({ type: "message:trace", messageId: activePending.id, trace: runnerTrace }); + dispatch({ type: "message:fail", messageId: activePending.id, message: failedWithTrace }); + currentWorkspace = await persistConversation({ workspace: currentWorkspace ?? state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: replaceMessage(visibleMessages, activePending.id, failedWithTrace) }); if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace }); } dispatch({ type: "chat:done" }); @@ -373,3 +379,29 @@ export function useWorkbenchStore(enabled: boolean, projectId = WORKBENCH_PROJEC }, []); return { state, sessionTabs, activeConversationId, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, submitMessage, cancelAgentMessage, retryAgentMessage, replayAgentTrace, clearConversation, recordActivity, setProviderProfile, setCodeAgentTimeoutMs, setGatewayShellTimeoutMs }; } + +function findActiveAgentMessage(messages: ChatMessage[], traceId: string, conversationId: string): ChatMessage | null { + for (const message of [...messages].reverse()) { + if (message.role !== "agent") continue; + if (message.traceId !== traceId && message.runnerTrace?.traceId !== traceId) continue; + if (message.conversationId && message.conversationId !== conversationId) continue; + if (!isActiveAgentStatus(message.status) && !isActiveAgentStatus(message.runnerTrace?.status)) continue; + return message; + } + return null; +} + +function isActiveAgentStatus(value: unknown): boolean { + return ["running", "pending", "accepted", "processing"].includes(String(value ?? "").trim().toLowerCase()); +} + +function insertBeforeMessage(messages: ChatMessage[], messageId: string, inserted: ChatMessage): ChatMessage[] { + if (messages.some((message) => message.id === inserted.id)) return messages; + const index = messages.findIndex((message) => message.id === messageId); + if (index < 0) return [...messages, inserted]; + return [...messages.slice(0, index), inserted, ...messages.slice(index)]; +} + +function replaceMessage(messages: ChatMessage[], messageId: string, replacement: ChatMessage): ChatMessage[] { + return messages.map((message) => message.id === messageId ? replacement : message); +}