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 assert from "node:assert/strict";
|
||||||
import { test } from "bun:test";
|
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";
|
import type { ChatMessage } from "../../types/domain";
|
||||||
|
|
||||||
const baseAgentMessage: ChatMessage = {
|
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);
|
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", () => {
|
test("conversation panel does not delay markdown rendering behind first-screen fallbacks", async () => {
|
||||||
assert.ok(FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS > 3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("conversation panel passes the first-screen markdown delay to historical message bodies", async () => {
|
|
||||||
const source = await Bun.file(new URL("./ConversationPanel.tsx", import.meta.url)).text();
|
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 () => {
|
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;
|
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 {
|
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}`;
|
const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`;
|
||||||
@@ -149,11 +148,10 @@ function MessageCard({ message, firstScreenHistory, codeAgentTimeoutMs, onCancel
|
|||||||
defaultOpen={message.status === "running"}
|
defaultOpen={message.status === "running"}
|
||||||
storageKey={traceKey}
|
storageKey={traceKey}
|
||||||
collapseWhenFinalResponse={hasFinalResponse}
|
collapseWhenFinalResponse={hasFinalResponse}
|
||||||
markdownUpgradeDelayMs={firstScreenHistory ? FIRST_SCREEN_HISTORY_MARKDOWN_DELAY_MS : undefined}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="message-final-response" aria-label="Final Response" data-first-screen-history={firstScreenHistory ? "true" : undefined}>
|
<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>
|
</section>
|
||||||
<MessageDebug
|
<MessageDebug
|
||||||
message={message}
|
message={message}
|
||||||
@@ -166,7 +164,7 @@ function MessageCard({ message, firstScreenHistory, codeAgentTimeoutMs, onCancel
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<article className={`message-card message-${message.role} tone-border-${toneClass(message.status)}`} data-message-status={message.status}>
|
<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
|
<MessageDebug
|
||||||
message={message}
|
message={message}
|
||||||
codeAgentTimeoutMs={codeAgentTimeoutMs}
|
codeAgentTimeoutMs={codeAgentTimeoutMs}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { MessageTracePanel } from "./MessageTracePanel";
|
import { MessageTracePanel } from "./MessageTracePanel";
|
||||||
import type { RunnerTrace } from "../../types/domain";
|
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 = {
|
const trace: RunnerTrace = {
|
||||||
traceId: "trc_markdown_row",
|
traceId: "trc_markdown_row",
|
||||||
status: "completed",
|
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, /data-body-format="markdown"/u);
|
||||||
assert.match(html, /message-trace-markdown/u);
|
assert.match(html, /message-trace-markdown/u);
|
||||||
assert.match(html, /\| hwpodId \| node \|/u);
|
assert.match(html, /<table>/u);
|
||||||
assert.match(html, /`hwpod-f103`/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);
|
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", () => {
|
test("message trace panel exposes running status for live trace animation", () => {
|
||||||
const trace: RunnerTrace = {
|
const trace: RunnerTrace = {
|
||||||
traceId: "trc_running_row",
|
traceId: "trc_running_row",
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ interface MessageTracePanelProps {
|
|||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
collapseWhenFinalResponse?: boolean;
|
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 traceRunning = isRunningTrace(trace);
|
||||||
const shouldCollapseForFinalResponse = collapseWhenFinalResponse === true && !traceRunning;
|
const shouldCollapseForFinalResponse = collapseWhenFinalResponse === true && !traceRunning;
|
||||||
const storedOpen = shouldCollapseForFinalResponse ? false : readStoredOpen(storageKey, defaultOpen ?? 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-tone tone-${toneClass(row.tone)}`} aria-hidden="true">●</span>
|
||||||
<span className="message-trace-label">{row.header}</span>
|
<span className="message-trace-label">{row.header}</span>
|
||||||
</header>
|
</header>
|
||||||
{row.body ? <TraceRowBody row={row} markdownUpgradeDelayMs={markdownUpgradeDelayMs} /> : null}
|
{row.body ? <TraceRowBody row={row} /> : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</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.body) return null;
|
||||||
if (row.bodyFormat === "markdown") {
|
if (row.bodyFormat === "markdown") {
|
||||||
return (
|
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}
|
{row.body}
|
||||||
</MessageMarkdown>
|
</MessageMarkdown>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { MarkdownRenderer } from "./MarkdownRenderer";
|
import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||||
import { MessageMarkdown, PlainMessageText } from "./MessageMarkdown";
|
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();
|
const source = await Bun.file(new URL("./MessageMarkdown.tsx", import.meta.url)).text();
|
||||||
|
|
||||||
assert.doesNotMatch(source, /from "react-markdown"/u);
|
assert.doesNotMatch(source, /from "react-markdown"/u);
|
||||||
assert.match(source, /lazy\(\(\) => import\("\.\/MarkdownRenderer"\)/u);
|
assert.doesNotMatch(source, /setTimeout/u);
|
||||||
assert.match(source, /upgradeDelayMs = 1800/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", () => {
|
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", () => {
|
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, /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", () => {
|
test("plain first-screen fallback escapes raw HTML", () => {
|
||||||
|
|||||||
@@ -1,33 +1,20 @@
|
|||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { lazy, Suspense, useEffect, useState } from "react";
|
import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||||
|
|
||||||
interface MessageMarkdownProps {
|
interface MessageMarkdownProps {
|
||||||
children: string;
|
children: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
upgradeDelayMs?: number;
|
|
||||||
[key: `data-${string}`]: string | undefined;
|
[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 (
|
return (
|
||||||
<div className={className} {...props}>
|
<div className={className} {...props}>
|
||||||
<DeferredMarkdown source={children} upgradeDelayMs={upgradeDelayMs} fallback={<PlainMessageText>{children}</PlainMessageText>} />
|
<MarkdownRenderer>{children}</MarkdownRenderer>
|
||||||
</div>
|
</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 {
|
export function PlainMessageText({ children }: { children: string }): ReactElement {
|
||||||
return <p>{children}</p>;
|
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", () => {
|
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 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 pendingIndex = source.indexOf('dispatch({ type: "message:pending"');
|
||||||
const persistIndex = source.indexOf("let currentWorkspace = await persistConversation", pendingIndex);
|
const persistIndex = source.indexOf("let currentWorkspace = await persistConversation", pendingIndex);
|
||||||
const routeIndex = source.indexOf("const route = steerMode ? api.steerAgentMessage : api.sendAgentMessage", pendingIndex);
|
const routeIndex = source.indexOf("const route = steerMode ? api.steerAgentMessage : api.sendAgentMessage", pendingIndex);
|
||||||
|
assert.ok(visibleMessagesIndex > 0);
|
||||||
assert.ok(pendingIndex > 0);
|
assert.ok(pendingIndex > 0);
|
||||||
assert.ok(persistIndex > pendingIndex);
|
assert.ok(persistIndex > pendingIndex);
|
||||||
assert.ok(routeIndex > persistIndex);
|
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", () => {
|
test("issue 853 submit failure persists the same trace before refresh", () => {
|
||||||
const source = fs.readFileSync(path.join(srcRoot, "state/workbench.ts"), "utf8");
|
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 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 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(failureIndex > 0);
|
||||||
assert.ok(failedWithTraceIndex > failureIndex);
|
assert.ok(failedWithTraceIndex > failureIndex);
|
||||||
assert.ok(persistIndex > failedWithTraceIndex);
|
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");
|
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", () => {
|
test("conversation selection restores steer mode when switching back to a running session", () => {
|
||||||
const state = baseState({ workspace: workspace("cnv_done", null), messages: [], chatPending: false });
|
const state = baseState({ workspace: workspace("cnv_done", null), messages: [], chatPending: false });
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type Action =
|
|||||||
| { type: "timeout:set"; codeAgentTimeoutMs: number }
|
| { type: "timeout:set"; codeAgentTimeoutMs: number }
|
||||||
| { type: "gateway-timeout:set"; gatewayShellTimeoutMs: number }
|
| { type: "gateway-timeout:set"; gatewayShellTimeoutMs: number }
|
||||||
| { type: "message:pending"; user: ChatMessage; pending: ChatMessage; request: NonNullable<WorkbenchState["currentRequest"]> }
|
| { 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:replace"; messageId: string; message: ChatMessage }
|
||||||
| { type: "message:trace-status"; messageId: string; status: string }
|
| { type: "message:trace-status"; messageId: string; status: string }
|
||||||
| { type: "message:trace"; messageId: string; trace: NonNullable<ChatMessage["runnerTrace"]> }
|
| { 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 "timeout:set": return { ...state, codeAgentTimeoutMs: action.codeAgentTimeoutMs };
|
||||||
case "gateway-timeout:set": return { ...state, gatewayShellTimeoutMs: action.gatewayShellTimeoutMs };
|
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: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:replace": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message) };
|
||||||
case "message:trace-status": {
|
case "message:trace-status": {
|
||||||
const updated = state.messages.map((message) => message.id === action.messageId ? { ...message, traceReplayStatus: action.status, updatedAt: new Date().toISOString() } : message);
|
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);
|
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 {
|
export function composerFromState(state: WorkbenchState, activeConversationId: string | null): ComposerState {
|
||||||
const latestMessage = latestConversationMessage(state.messages, activeConversationId);
|
const latestMessage = latestConversationMessage(state.messages, activeConversationId);
|
||||||
const runningMessage = findRunningAgentMessage(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();
|
const value = text.trim();
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
const steerMode = composer.submitMode === "steer";
|
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 steerTraceId = steerMode ? nextProtocolId("trc_steer") : null;
|
||||||
|
const displayTraceId = steerTraceId ?? traceId;
|
||||||
const conversationId = composer.conversationId ?? activeConversationId ?? nextProtocolId("cnv");
|
const conversationId = composer.conversationId ?? activeConversationId ?? nextProtocolId("cnv");
|
||||||
const sessionId = composer.sessionId ?? state.workspace?.selectedAgentSessionId ?? state.workspace?.workspace?.selectedAgentSessionId ?? null;
|
const sessionId = composer.sessionId ?? state.workspace?.selectedAgentSessionId ?? state.workspace?.workspace?.selectedAgentSessionId ?? null;
|
||||||
const threadId = composer.threadId ?? state.workspace?.workspace?.threadId ?? 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 pending = makeMessage("agent", "正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent 处理中" });
|
||||||
const pendingWithRetry: ChatMessage = { ...pending, retryInput: value };
|
const pendingWithRetry: ChatMessage = { ...pending, retryInput: value };
|
||||||
dispatch({ type: "message:pending", user, pending: pendingWithRetry, request: { traceId, conversationId, sessionId, threadId } });
|
const existingActivePending = steerMode ? findActiveAgentMessage(state.messages, traceId, conversationId) : null;
|
||||||
let currentWorkspace = await persistConversation({ workspace: state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: [...state.messages, user, pendingWithRetry] });
|
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 });
|
if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace });
|
||||||
const route = steerMode ? api.steerAgentMessage : api.sendAgentMessage;
|
const route = steerMode ? api.steerAgentMessage : api.sendAgentMessage;
|
||||||
updateActivity();
|
updateActivity();
|
||||||
@@ -248,13 +254,13 @@ export function useWorkbenchStore(enabled: boolean, projectId = WORKBENCH_PROJEC
|
|||||||
gatewayShellTimeoutMs: state.gatewayShellTimeoutMs,
|
gatewayShellTimeoutMs: state.gatewayShellTimeoutMs,
|
||||||
workspaceId: currentWorkspace?.workspaceId ?? state.workspace?.workspaceId,
|
workspaceId: currentWorkspace?.workspaceId ?? state.workspace?.workspaceId,
|
||||||
workspaceRevision: currentWorkspace?.revision ?? state.workspace?.revision,
|
workspaceRevision: currentWorkspace?.revision ?? state.workspace?.revision,
|
||||||
targetTraceId: steerMode ? traceId : null,
|
targetTraceId,
|
||||||
steerTraceId
|
steerTraceId
|
||||||
}, state.codeAgentTimeoutMs, submitActivityRef);
|
}, state.codeAgentTimeoutMs, submitActivityRef);
|
||||||
const finalize = async (result: AgentChatResultResponse, availability: CodeAgentAvailability | null): Promise<void> => {
|
const finalize = async (result: AgentChatResultResponse, availability: CodeAgentAvailability | null): Promise<void> => {
|
||||||
const completed = messageFromAgentResponse(pending.id, pending, result);
|
const completed = messageFromAgentResponse(activePending.id, activePending, result);
|
||||||
dispatch({ type: "message:complete", messageId: pending.id, message: completed, availability: availability ?? result.availability ?? null });
|
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: [...state.messages, user, completed] });
|
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 });
|
if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace });
|
||||||
const conversations = await api.conversations(activeProjectId);
|
const conversations = await api.conversations(activeProjectId);
|
||||||
if (conversations.ok) dispatch({ type: "conversation:list", conversations: conversations.data?.conversations ?? [] });
|
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(),
|
onActivity: () => updateActivity(),
|
||||||
onSnapshot: (snapshot) => {
|
onSnapshot: (snapshot) => {
|
||||||
lastSnapshot = 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) => {
|
onComplete: (result) => {
|
||||||
terminalResult = 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
|
// HWLAB #802: persist fail too. The user refreshing should see
|
||||||
// the frontend-reported failure, not a silent "running" state.
|
// 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 超时" });
|
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 });
|
dispatch({ type: "message:fail", messageId: activePending.id, message: failed });
|
||||||
void persistConversation({ workspace: currentWorkspace ?? state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: [...state.messages, user, 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 }); });
|
.then((workspace) => { if (workspace) dispatch({ type: "workspace:sync", workspace }); });
|
||||||
subscriptionController.abort();
|
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 failed = makeMessage("agent", response.error ?? "Code Agent 请求失败", "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 请求失败" });
|
||||||
const runnerTrace = traceSnapshot ? snapshotToRunnerTrace(traceSnapshot) : null;
|
const runnerTrace = traceSnapshot ? snapshotToRunnerTrace(traceSnapshot) : null;
|
||||||
const failedWithTrace = runnerTrace ? { ...failed, runnerTrace } : failed;
|
const failedWithTrace = runnerTrace ? { ...failed, runnerTrace } : failed;
|
||||||
if (runnerTrace) dispatch({ type: "message:trace", messageId: pending.id, trace: runnerTrace });
|
if (runnerTrace) dispatch({ type: "message:trace", messageId: activePending.id, trace: runnerTrace });
|
||||||
dispatch({ type: "message:fail", messageId: pending.id, message: failedWithTrace });
|
dispatch({ type: "message:fail", messageId: activePending.id, message: failedWithTrace });
|
||||||
currentWorkspace = await persistConversation({ workspace: currentWorkspace ?? state.workspace, projectId: activeProjectId, conversationId, sessionId, threadId, messages: [...state.messages, user, 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 });
|
if (currentWorkspace) dispatch({ type: "workspace:sync", workspace: currentWorkspace });
|
||||||
}
|
}
|
||||||
dispatch({ type: "chat:done" });
|
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 };
|
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