Merge pull request #947 from pikasTech/fix/issue943-webui-steer-markdown
fix: improve WebUI steer and markdown rendering
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user