Merge pull request #947 from pikasTech/fix/issue943-webui-steer-markdown

fix: improve WebUI steer and markdown rendering
This commit is contained in:
Lyon
2026-06-05 23:20:10 +08:00
committed by GitHub
10 changed files with 143 additions and 65 deletions
@@ -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 () => {
@@ -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}
<section className="message-final-response" aria-label="Final Response" data-first-screen-history={firstScreenHistory ? "true" : undefined}>
{hasFinalResponse ? <MessageMarkdown upgradeDelayMs={firstScreenHistory ? FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS : undefined}>{message.text}</MessageMarkdown> : null}
{hasFinalResponse ? <MessageMarkdown>{message.text}</MessageMarkdown> : null}
</section>
<MessageDebug
message={message}
@@ -166,7 +164,7 @@ function MessageCard({ message, firstScreenHistory, codeAgentTimeoutMs, onCancel
}
return (
<article className={`message-card message-${message.role} tone-border-${toneClass(message.status)}`} data-message-status={message.status}>
<MessageMarkdown upgradeDelayMs={firstScreenHistory ? FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS : undefined}>{message.text}</MessageMarkdown>
<MessageMarkdown>{message.text}</MessageMarkdown>
<MessageDebug
message={message}
codeAgentTimeoutMs={codeAgentTimeoutMs}
@@ -5,7 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { MessageTracePanel } from "./MessageTracePanel";
import type { RunnerTrace } from "../../types/domain";
test("message trace panel marks assistant markdown rows for deferred shared rendering", () => {
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, /<table>/u);
assert.match(html, /<code>hwpod-f103<\/code>/u);
assert.doesNotMatch(html, /<pre class="message-trace-body message-copy-markdown" data-body-format="markdown"/u);
});
test("message trace panel accepts a delayed markdown upgrade for first-screen history", () => {
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",
@@ -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
<span className={`message-trace-tone tone-${toneClass(row.tone)}`} aria-hidden="true"></span>
<span className="message-trace-label">{row.header}</span>
</header>
{row.body ? <TraceRowBody row={row} markdownUpgradeDelayMs={markdownUpgradeDelayMs} /> : null}
{row.body ? <TraceRowBody row={row} /> : null}
</li>
))}
</ol>
@@ -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 (
<MessageMarkdown className="message-trace-body message-trace-markdown message-copy-markdown" data-body-format="markdown" upgradeDelayMs={markdownUpgradeDelayMs}>
<MessageMarkdown className="message-trace-body message-trace-markdown message-copy-markdown" data-body-format="markdown">
{row.body}
</MessageMarkdown>
);
@@ -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, /<MarkdownRenderer>\{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(<MessageMarkdown>{"第一行\n第二行"}</MessageMarkdown>);
const html = renderToStaticMarkup(<MessageMarkdown>{"第一行\n\n第二行"}</MessageMarkdown>);
assert.match(html, /class="message-body"/u);
assert.match(html, /\n/u);
assert.match(html, /<p><\/p>/u);
assert.match(html, /<p><\/p>/u);
});
test("plain first-screen fallback escapes raw HTML", () => {
@@ -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 (
<div className={className} {...props}>
<DeferredMarkdown source={children} upgradeDelayMs={upgradeDelayMs} fallback={<PlainMessageText>{children}</PlainMessageText>} />
<MarkdownRenderer>{children}</MarkdownRenderer>
</div>
);
}
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 <Suspense fallback={fallback}><MarkdownRenderer>{source}</MarkdownRenderer></Suspense>;
}
export function PlainMessageText({ children }: { children: string }): ReactElement {
return <p>{children}</p>;
}
@@ -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);
@@ -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 });
@@ -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<WorkbenchState["currentRequest"]> }
| { type: "message:steer"; user: ChatMessage; targetMessageId: string; request: NonNullable<WorkbenchState["currentRequest"]> }
| { type: "message:replace"; messageId: string; message: ChatMessage }
| { type: "message:trace-status"; messageId: string; status: string }
| { type: "message:trace"; messageId: string; trace: NonNullable<ChatMessage["runnerTrace"]> }
@@ -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);
+46 -14
View File
@@ -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<void> => {
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);
}