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);
+}