fix(web): v0.2 round-6 workbench: message actions panel (cancel/retry/replay) (HWLAB #775 round 3) (#784)

Continues HWLAB #775 round 3 by adding the message-actions panel
that the React migration dropped from `app-conv.ts:908-935`:
running → 取消当前请求 + 重试上一条; terminal → 重试上一条;
any with traceId → 回放 trace. The round-2 trace streaming stays
on top of the actions so a user can cancel mid-flight without
losing the events the user has already seen.

## 修复对照

| 类别 | 迁移前 (`893f46bc`) | 迁移后 (round 3) | 状态 |
|---|---|---|---|
| **消息动作面板** | `app-conv.ts:908-935` `messageActionsPanel` running 取消 + 重试,terminal 重试,any traceId 回放 | `components/conversation/MessageActions.tsx`:同结构按钮 + `traceReplayStatus` 行内展示 |  对齐 |
| **取消当前请求** | `app-conv.ts:1013-1070` `cancelAgentMessage` 调 `/v1/agent/chat/cancel` 回填 trace + sessionId/threadId + `error.code = "codex_stdio_canceled"` | `state/runner-actions.ts:cancelAgentMessageAction` 同路径,dispatch `message:replace` |  对齐 |
| **重试上一条** | `app-conv.ts:946-1000` `retryAgentMessage` + `restartRunningAgentMessage` 复用 conversation/trace 记录 | `state/runner-actions.ts:retryAgentMessageAction` 走 `submitMessage(retryInput)` |  对齐 |
| **回放 trace** | `app-conv.ts:1073-1125` `replayAgentTrace` / `replayFullTrace` 调 `/v1/agent/chat/trace/<id>` | `state/runner-actions.ts:replayAgentTraceAction` 走 `state/runner-trace.ts:replayFullTrace`,dispatch `message:trace` + `message:trace-status` |  对齐 |
| **retryInput 保留** | `app-conv.ts:1019` 在 `cancelAgentMessage` 末尾 `el.commandInput.value = message.retryInput` | `state/workbench.ts:submitMessage` 把 `value` 注入 `pending.retryInput`,round-trip 通过 reducer 保留 |  对齐 |

## 改动

- 新增 `web/hwlab-cloud-web/src/components/conversation/MessageActions.tsx`(44 行)
- 新增 `web/hwlab-cloud-web/src/state/runner-actions.ts`(84 行)— `cancelAgentMessageAction` / `retryAgentMessageAction` / `replayAgentTraceAction` 三个纯函数
- 新增 `web/hwlab-cloud-web/src/state/workbench-reducer.ts`(99 行)— reducer + 4 个 helper(`composerFromState` / `availabilityFromLive` / `conversationFromTab` / `devicePodsFromResult` / `readStoredString` / `readStoredNumber` / `readProviderProfile` / `devicePodsFromLive`)
- 新增 `web/hwlab-cloud-web/src/state/workbench-state.ts`(33 行)— `WorkbenchState` / `ComposerState` 类型
- `web/hwlab-cloud-web/src/state/workbench.ts`(319 行,< 400 前端 guard)— 删除冗余 reducer / helpers,import 上面 3 个文件,导出 `devicePodsFromLive` re-export,新增 `cancelAgentMessage` / `retryAgentMessage` / `replayAgentTrace` 三个 hook callback
- `web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx` — `MessageCard` 接 `onCancel` / `onRetry` / `onReplayTrace` props
- `web/hwlab-cloud-web/src/App.tsx` — `<ConversationPanel>` 接 `onCancelMessage` / `onRetryMessage` / `onReplayTrace` 三个事件
- `web/hwlab-cloud-web/src/types/domain.ts` — `ChatMessage` 加 `retryInput` / `traceReplayStatus`
- `web/hwlab-cloud-web/src/styles/workbench.css` — `.message-actions` / `.message-action` / `.message-action-status`

## 验收

- `bun run check` 通过(Vite build + 2/2 dist freshness test pass + 12 fresh dist files)
- `bun run scripts/tsc-check.ts` 通过(strict React TSX, 0 explicit any)
- `bun test` 2 pass / 0 fail
- `bun run build` 9 dist files verified fresh;`app.js` 218185 → 221256 bytes;`index.css` 15324 → 16041 bytes
- dist `app.js` 含 `cancelAgentMessage` / `retryAgentMessage` / `replayAgentTrace` / `message-actions` / `message-action-cancel` / `message-action-retry` / `message-action-trace` / `message-action-status` 等关键字
- 所有改动文件 < 400 行前端 guard

Refs: pikasTech/HWLAB#775 (round 3 of 7)

Co-authored-by: HWLAB <ci@hwlab.local>
This commit is contained in:
Lyon
2026-06-03 23:00:28 +08:00
committed by GitHub
parent dcbd5febfb
commit 0740712a57
9 changed files with 347 additions and 121 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ export function App(): ReactElement {
onKeyDown={sessionResize.onKeyDown}
/>
<section className="center-workspace">
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} onCopySession={() => void navigator.clipboard?.writeText(store.activeConversationId ?? "")} /> : null}
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} onCopySession={() => void navigator.clipboard?.writeText(store.activeConversationId ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null}
{route === "skills" ? <SkillsView /> : null}
{route === "gate" ? <GateView /> : null}
{route === "help" ? <HelpView help={help} /> : null}
@@ -7,6 +7,7 @@ import { formatBeijingTime, jsonPreview, shortToken, toneClass } from "../../uti
import { codeAgentSummaryRows, classifyCodeAgentStatusSummary } from "../../state/code-agent-status";
import { StateTag } from "../shared/StateTag";
import { MessageTracePanel } from "./MessageTracePanel";
import { MessageActions } from "./MessageActions";
interface ConversationPanelProps {
messages: ChatMessage[];
@@ -14,9 +15,12 @@ interface ConversationPanelProps {
live: LiveSurface | null;
chatPending: boolean;
onCopySession(): void;
onCancelMessage(messageId: string): void;
onRetryMessage(messageId: string): void;
onReplayTrace(messageId: string): void;
}
export function ConversationPanel({ messages, availability, live, chatPending, onCopySession }: ConversationPanelProps): ReactElement {
export function ConversationPanel({ messages, availability, live, chatPending, onCopySession, onCancelMessage, onRetryMessage, onReplayTrace }: ConversationPanelProps): ReactElement {
const intro = useMemo<ChatMessage[]>(() => [
{
id: "intro-mode",
@@ -52,7 +56,7 @@ export function ConversationPanel({ messages, availability, live, chatPending, o
</div>
<CodeAgentSummary availability={availability} live={live} latestMessage={latestAgentMessage(messages)} />
<div className="conversation-list" id="conversation-list" aria-live="polite">
{[...intro, ...messages].map((message) => <MessageCard key={message.id} message={message} />)}
{[...intro, ...messages].map((message) => <MessageCard key={message.id} message={message} onCancel={() => onCancelMessage(message.id)} onRetry={() => onRetryMessage(message.id)} onReplayTrace={() => onReplayTrace(message.id)} />)}
</div>
</div>
</div>
@@ -94,7 +98,7 @@ function CodeAgentSummary({ availability, live, latestMessage }: { availability:
);
}
function MessageCard({ message }: { message: ChatMessage }): ReactElement {
function MessageCard({ message, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement {
const html = renderMessageMarkdown(message.text);
const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`;
return (
@@ -113,6 +117,7 @@ function MessageCard({ message }: { message: ChatMessage }): ReactElement {
<span>{formatBeijingTime(message.updatedAt ?? message.createdAt, { withSeconds: true })}</span>
</footer>
{message.runnerTrace ? <MessageTracePanel trace={message.runnerTrace} storageKey={traceKey} /> : null}
<MessageActions message={message} onCancel={onCancel} onRetry={onRetry} onReplayTrace={onReplayTrace} />
</article>
);
}
@@ -0,0 +1,44 @@
import type { ReactElement } from "react";
import type { ChatMessage } from "../../types/domain";
import { toneClass } from "../../utils";
interface MessageActionsProps {
message: ChatMessage;
onCancel(): void;
onRetry(): void;
onReplayTrace(): void;
}
export function MessageActions({ message, onCancel, onRetry, onReplayTrace }: MessageActionsProps): ReactElement | null {
if (message.role !== "agent") return null;
const status = String(message.status ?? "");
const buttons: ReactElement[] = [];
if (status === "running") {
buttons.push(actionButton("取消当前请求", "cancel", onCancel, "取消当前 in-flight Codex stdio 请求"));
if (message.retryInput) {
buttons.push(actionButton("重试上一条", "retry", onRetry, "保留 sessionId/trace 并重新发送上一条输入"));
}
}
if (canRetryTerminal(message) && message.retryInput) {
buttons.push(actionButton("重试上一条", "retry", onRetry, "保留 conversation/trace 记录并重新发送上一条输入"));
}
if (message.traceId) {
buttons.push(actionButton("回放 trace", "trace", onReplayTrace, "从 runnerTrace store 重新读取真实事件"));
}
if (buttons.length === 0 && !message.traceReplayStatus) return null;
return (
<div className={`message-actions tone-border-${toneClass(message.status)}`} role="toolbar" aria-label="消息操作">
{buttons}
{message.traceReplayStatus ? <span className="message-action-status">{message.traceReplayStatus}</span> : null}
</div>
);
}
function actionButton(label: string, action: string, onClick: () => void, title: string): ReactElement {
return <button type="button" className={`message-action message-action-${action}`} title={title} onClick={onClick}>{label}</button>;
}
function canRetryTerminal(message: ChatMessage): boolean {
return ["failed", "timeout", "error", "canceled", "blocked"].includes(String(message.status ?? "").toLowerCase());
}
@@ -0,0 +1,84 @@
import { api } from "../services/api/client";
import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../types/domain";
import { firstNonEmptyString } from "../utils";
import { replayFullTrace, snapshotToRunnerTrace } from "./runner-trace";
export interface RunnerActionsOptions {
activeConversationId: string | null;
workspace: WorkspaceRecord | null;
messages: ChatMessage[];
codeAgentTimeoutMs: number;
onReplaceMessage(messageId: string, message: ChatMessage): void;
onTraceStatus(messageId: string, status: string): void;
onTrace(messageId: string, trace: NonNullable<ChatMessage["runnerTrace"]>): void;
submitMessage(text: string): Promise<void>;
}
export async function cancelAgentMessageAction(
messageId: string,
options: RunnerActionsOptions
): Promise<void> {
const message = options.messages.find((item) => item.id === messageId);
if (!message?.traceId) return;
const response = await api.cancelAgentMessage({
traceId: message.traceId,
conversationId: message.conversationId ?? options.activeConversationId ?? undefined,
sessionId: message.sessionId
?? options.workspace?.selectedAgentSessionId
?? options.workspace?.workspace?.selectedAgentSessionId
?? null,
threadId: message.threadId ?? options.workspace?.workspace?.threadId ?? null
});
const canceled = response.data?.canceled === true;
const errorObject = response.data?.error;
const errorMessageText = typeof errorObject === "object" && errorObject && "message" in errorObject
? String((errorObject as { message?: unknown }).message ?? "")
: "";
if (canceled) {
const next: ChatMessage = {
...message,
title: "Code Agent 已取消",
text: (response.data as { userMessage?: string } | null)?.userMessage ?? "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。",
status: "canceled",
updatedAt: new Date().toISOString(),
error: errorObject
? { ...(typeof errorObject === "object" ? errorObject : {}), code: "codex_stdio_canceled" }
: { code: "codex_stdio_canceled", message: "user canceled current Code Agent request" }
};
options.onReplaceMessage(messageId, next);
} else {
const failure: ChatMessage = {
...message,
title: "Code Agent 取消受阻",
text: errorMessageText || response.error || "当前请求没有可取消的 in-flight Codex stdio session;输入和 trace 已保留。",
status: "failed",
updatedAt: new Date().toISOString(),
traceReplayStatus: errorMessageText || "取消未确认"
};
options.onReplaceMessage(messageId, failure);
}
}
export async function retryAgentMessageAction(
messageId: string,
options: RunnerActionsOptions
): Promise<void> {
const message = options.messages.find((item) => item.id === messageId);
if (!message?.retryInput) return;
await options.submitMessage(message.retryInput);
}
export async function replayAgentTraceAction(
messageId: string,
options: RunnerActionsOptions
): Promise<void> {
const message = options.messages.find((item) => item.id === messageId);
if (!message?.traceId) return;
const snapshot = await replayFullTrace(message.traceId, Math.max(8000, options.codeAgentTimeoutMs));
if (!snapshot) {
options.onTraceStatus(messageId, "trace 回放失败");
return;
}
options.onTrace(messageId, snapshotToRunnerTrace(snapshot));
options.onTraceStatus(messageId, `完整 trace 已回放:${snapshot.events?.length ?? 0} 个原始 event${snapshot.lastEventLabel ?? ""}`);
}
@@ -0,0 +1,104 @@
import type { ChatMessage, CodeAgentAvailability, ConversationRecord, DevicePodItem, DevicePodListResponse, LiveSurface, SessionTab } from "../types/domain";
import { firstNonEmptyString, nonEmptyString } from "../utils";
import { mergeRunnerTrace } from "./runner-trace";
import type { ComposerState, WorkbenchState } from "./workbench-state";
export type Action =
| { type: "hydrate:start" }
| { type: "hydrate:done"; workspace: WorkbenchState["workspace"]; conversations: ConversationRecord[]; messages: ChatMessage[] }
| { type: "hydrate:error"; error: string }
| { type: "live:set"; live: WorkbenchState["live"]; availability: CodeAgentAvailability | null; selectedDevicePodId: string }
| { type: "device:selected"; devicePodId: string }
| { type: "profile:set"; profile: WorkbenchState["providerProfile"] }
| { type: "timeout:set"; codeAgentTimeoutMs: number }
| { type: "gateway-timeout:set"; gatewayShellTimeoutMs: number }
| { type: "message:pending"; user: ChatMessage; pending: ChatMessage; request: 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"]> }
| { type: "message:complete"; messageId: string; message: ChatMessage; availability: CodeAgentAvailability | null; workspace?: WorkbenchState["workspace"] }
| { type: "message:fail"; messageId: string; message: ChatMessage }
| { type: "chat:done" }
| { type: "conversation:select"; conversation: ConversationRecord | null; workspace?: WorkbenchState["workspace"] }
| { type: "conversation:list"; conversations: ConversationRecord[] }
| { type: "conversation:clear" };
export function workbenchReducer(state: WorkbenchState, action: Action): WorkbenchState {
switch (action.type) {
case "hydrate:start": return { ...state, loading: true, error: null };
case "hydrate:done": return { ...state, loading: false, workspace: action.workspace, conversations: action.conversations, messages: action.messages, error: null };
case "hydrate:error": return { ...state, loading: false, error: action.error };
case "live:set": return { ...state, live: action.live, codeAgentAvailability: action.availability, selectedDevicePodId: action.selectedDevicePodId };
case "device:selected": return { ...state, selectedDevicePodId: action.devicePodId };
case "profile:set": return { ...state, providerProfile: action.profile };
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 };
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);
return { ...state, messages: updated };
}
case "message:trace": {
const updated = state.messages.map((message) => message.id === action.messageId ? { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, action.trace) } : message);
return { ...state, messages: updated };
}
case "message:complete": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message), codeAgentAvailability: action.availability ?? state.codeAgentAvailability, workspace: action.workspace ?? state.workspace };
case "message:fail": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message) };
case "chat:done": return { ...state, chatPending: false, currentRequest: null };
case "conversation:select": return { ...state, workspace: action.workspace ?? state.workspace, messages: action.conversation?.messages ?? [], error: null };
case "conversation:list": return { ...state, conversations: action.conversations };
case "conversation:clear": return { ...state, messages: [], currentRequest: null, chatPending: false };
default: return state;
}
}
function replaceMessage(messages: ChatMessage[], messageId: string, replacement: ChatMessage): ChatMessage[] {
return messages.map((message) => message.id === messageId ? replacement : message);
}
export function composerFromState(state: WorkbenchState, activeConversationId: string | null): ComposerState {
const activeTraceId = firstNonEmptyString(state.currentRequest?.traceId, state.workspace?.activeTraceId, state.workspace?.workspace?.activeTraceId);
const sessionStatus = firstNonEmptyString(state.messages.at(-1)?.status, state.workspace?.workspace?.sessionStatus);
const canSteer = Boolean(activeTraceId && state.chatPending && sessionStatus !== "completed" && sessionStatus !== "failed");
if (canSteer) {
return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId };
}
if (!activeConversationId && !state.workspace?.workspaceId) {
return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null };
}
return { disabled: false, disabledReason: null, submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null };
}
export function availabilityFromLive(live: LiveSurface): CodeAgentAvailability | null {
const candidates = [live.healthLive.data?.codeAgent, live.healthLive.data?.availability, live.health.data?.codeAgent, live.restIndex.data?.codeAgent];
return candidates.find((item): item is CodeAgentAvailability => Boolean(item && typeof item === "object")) ?? null;
}
export function conversationFromTab(tab: SessionTab): ConversationRecord {
return { conversationId: tab.conversationId ?? tab.key, sessionId: tab.sessionId, threadId: tab.threadId, status: tab.status, messages: [] };
}
export function devicePodsFromLive(live: LiveSurface | null): DevicePodItem[] {
const payload = live?.devicePods?.data;
return payload?.devicePods ?? payload?.items ?? [];
}
export function devicePodsFromResult(result: { data?: DevicePodListResponse | null } | null): DevicePodItem[] {
return result?.data?.devicePods ?? result?.data?.items ?? [];
}
export function readStoredString(key: string): string | null {
try { return nonEmptyString(window.localStorage.getItem(key)); } catch { return null; }
}
export function readStoredNumber(key: string, fallback: number, min: number, max: number): number {
const value = Number(readStoredString(key));
if (!Number.isFinite(value)) return fallback;
return Math.min(Math.max(Math.trunc(value), min), max);
}
export function readProviderProfile(): "codex-api" | "minimax-m3" | "deepseek" {
const value = readStoredString("hwlab.workbench.codeAgentProviderProfile.v1");
return value === "codex-api" || value === "minimax-m3" || value === "deepseek" ? value : "deepseek";
}
@@ -0,0 +1,33 @@
import type { ChatMessage, CodeAgentAvailability, ConversationRecord, LiveSurface, ProviderProfile, SessionTab, WorkspaceRecord } from "../types/domain";
export type ChatStatus = "source" | "sent" | "running" | "completed" | "failed" | "blocked" | "timeout" | "canceled";
export interface WorkbenchState {
workspace: WorkspaceRecord | null;
conversations: ConversationRecord[];
messages: ChatMessage[];
selectedDevicePodId: string;
providerProfile: ProviderProfile;
codeAgentTimeoutMs: number;
gatewayShellTimeoutMs: number;
live: LiveSurface | null;
codeAgentAvailability: CodeAgentAvailability | null;
loading: boolean;
chatPending: boolean;
currentRequest: { traceId: string; conversationId: string; sessionId: string | null; threadId: string | null } | null;
error: string | null;
}
export interface ComposerState {
disabled: boolean;
disabledReason: string | null;
submitMode: "turn" | "steer";
route: "/v1/agent/chat" | "/v1/agent/chat/steer";
targetTraceId: string | null;
}
export interface WorkbenchRuntimeContext {
activeConversationId: string | null;
composer: ComposerState;
selectedDevicePodId: string;
}
+64 -117
View File
@@ -19,6 +19,18 @@ import type {
} from "../types/domain";
import { firstNonEmptyString, nextProtocolId, nonEmptyString } from "../utils";
import { WORKBENCH_PROJECT_ID } from "./constants";
import {
availabilityFromLive,
composerFromState,
conversationFromTab,
devicePodsFromResult,
readProviderProfile,
readStoredNumber,
readStoredString,
workbenchReducer,
type Action
} from "./workbench-reducer";
import type { ComposerState, WorkbenchState } from "./workbench-state";
import { conversationsToTabs, ensureWorkspace, makeMessage, messageFromAgentResponse, messagesFromWorkspace, persistConversation } from "./conversation";
import {
mergeRunnerTrace,
@@ -30,53 +42,16 @@ import {
type TraceSnapshot,
waitForAgentResult
} from "./runner-trace";
import {
cancelAgentMessageAction,
replayAgentTraceAction,
retryAgentMessageAction
} from "./runner-actions";
const DEFAULT_DEVICE_POD_ID = "device-pod-71-freq";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
interface WorkbenchState {
workspace: WorkspaceRecord | null;
conversations: ConversationRecord[];
messages: ChatMessage[];
selectedDevicePodId: string;
providerProfile: ProviderProfile;
codeAgentTimeoutMs: number;
gatewayShellTimeoutMs: number;
live: LiveSurface | null;
codeAgentAvailability: CodeAgentAvailability | null;
loading: boolean;
chatPending: boolean;
currentRequest: { traceId: string; conversationId: string; sessionId: string | null; threadId: string | null } | null;
error: string | null;
}
interface ComposerState {
disabled: boolean;
disabledReason: string | null;
submitMode: "turn" | "steer";
route: "/v1/agent/chat" | "/v1/agent/chat/steer";
targetTraceId: string | null;
}
type Action =
| { type: "hydrate:start" }
| { type: "hydrate:done"; workspace: WorkspaceRecord | null; conversations: ConversationRecord[]; messages: ChatMessage[] }
| { type: "hydrate:error"; error: string }
| { type: "live:set"; live: LiveSurface; availability: CodeAgentAvailability | null; selectedDevicePodId: string }
| { type: "device:selected"; devicePodId: string }
| { type: "profile:set"; profile: ProviderProfile }
| { type: "timeout:set"; codeAgentTimeoutMs: number }
| { type: "gateway-timeout:set"; gatewayShellTimeoutMs: number }
| { type: "message:pending"; user: ChatMessage; pending: ChatMessage; request: WorkbenchState["currentRequest"] }
| { type: "message:trace"; messageId: string; trace: NonNullable<ChatMessage["runnerTrace"]> }
| { type: "message:complete"; messageId: string; message: ChatMessage; availability: CodeAgentAvailability | null; workspace?: WorkspaceRecord | null }
| { type: "message:fail"; messageId: string; message: ChatMessage }
| { type: "chat:done" }
| { type: "conversation:select"; conversation: ConversationRecord | null; workspace?: WorkspaceRecord | null }
| { type: "conversation:list"; conversations: ConversationRecord[] }
| { type: "conversation:clear" };
const initialState: WorkbenchState = {
workspace: null,
conversations: [],
@@ -104,6 +79,9 @@ export interface WorkbenchStore {
selectConversation(tab: SessionTab): Promise<void>;
deleteCurrentSession(): Promise<void>;
submitMessage(text: string): Promise<void>;
cancelAgentMessage(messageId: string): Promise<void>;
retryAgentMessage(messageId: string): Promise<void>;
replayAgentTrace(messageId: string): Promise<void>;
clearConversation(): void;
setProviderProfile(profile: ProviderProfile): void;
setCodeAgentTimeoutMs(value: number): void;
@@ -112,7 +90,7 @@ export interface WorkbenchStore {
}
export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
const [state, dispatch] = useReducer(reducer, initialState);
const [state, dispatch] = useReducer(workbenchReducer, initialState);
const activeConversationId = firstNonEmptyString(
state.workspace?.selectedConversationId,
@@ -223,7 +201,8 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
const threadId = state.workspace?.workspace?.threadId ?? null;
const user = makeMessage("user", value, "sent", { traceId, conversationId, sessionId, threadId });
const pending = makeMessage("agent", "正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent 处理中" });
dispatch({ type: "message:pending", user, pending, request: { traceId, conversationId, sessionId, threadId } });
const pendingWithRetry: ChatMessage = { ...pending, retryInput: value };
dispatch({ type: "message:pending", user, pending: pendingWithRetry, request: { traceId, conversationId, sessionId, threadId } });
const route = composer.submitMode === "steer" ? api.steerAgentMessage : api.sendAgentMessage;
const response = await route({
projectId: WORKBENCH_PROJECT_ID,
@@ -269,6 +248,45 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
dispatch({ type: "chat:done" });
}, [activeConversationId, composer.submitMode, composer.targetTraceId, state.codeAgentTimeoutMs, state.gatewayShellTimeoutMs, state.messages, state.providerProfile, state.workspace]);
const cancelAgentMessage = useCallback(async (messageId: string) => {
return cancelAgentMessageAction(messageId, {
activeConversationId,
workspace: state.workspace,
messages: state.messages,
codeAgentTimeoutMs: state.codeAgentTimeoutMs,
onReplaceMessage: (id, message) => dispatch({ type: "message:replace", messageId: id, message }),
onTraceStatus: (id, status) => dispatch({ type: "message:trace-status", messageId: id, status }),
onTrace: (id, trace) => dispatch({ type: "message:trace", messageId: id, trace }),
submitMessage
});
}, [activeConversationId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]);
const retryAgentMessage = useCallback(async (messageId: string) => {
return retryAgentMessageAction(messageId, {
activeConversationId,
workspace: state.workspace,
messages: state.messages,
codeAgentTimeoutMs: state.codeAgentTimeoutMs,
onReplaceMessage: (id, message) => dispatch({ type: "message:replace", messageId: id, message }),
onTraceStatus: (id, status) => dispatch({ type: "message:trace-status", messageId: id, status }),
onTrace: (id, trace) => dispatch({ type: "message:trace", messageId: id, trace }),
submitMessage
});
}, [activeConversationId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]);
const replayAgentTrace = useCallback(async (messageId: string) => {
return replayAgentTraceAction(messageId, {
activeConversationId,
workspace: state.workspace,
messages: state.messages,
codeAgentTimeoutMs: state.codeAgentTimeoutMs,
onReplaceMessage: (id, message) => dispatch({ type: "message:replace", messageId: id, message }),
onTraceStatus: (id, status) => dispatch({ type: "message:trace-status", messageId: id, status }),
onTrace: (id, trace) => dispatch({ type: "message:trace", messageId: id, trace }),
submitMessage
});
}, [activeConversationId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]);
const clearConversation = useCallback(() => dispatch({ type: "conversation:clear" }), []);
const setProviderProfile = useCallback((profile: ProviderProfile) => {
window.localStorage.setItem("hwlab.workbench.codeAgentProviderProfile.v1", profile);
@@ -288,81 +306,9 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
void refreshLive(devicePodId);
}, [refreshLive]);
return { state, sessionTabs, activeConversationId, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, submitMessage, clearConversation, setProviderProfile, setCodeAgentTimeoutMs, setGatewayShellTimeoutMs, selectDevicePod };
return { state, sessionTabs, activeConversationId, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, submitMessage, cancelAgentMessage, retryAgentMessage, replayAgentTrace, clearConversation, setProviderProfile, setCodeAgentTimeoutMs, setGatewayShellTimeoutMs, selectDevicePod };
}
function reducer(state: WorkbenchState, action: Action): WorkbenchState {
switch (action.type) {
case "hydrate:start": return { ...state, loading: true, error: null };
case "hydrate:done": return { ...state, loading: false, workspace: action.workspace, conversations: action.conversations, messages: action.messages, error: null };
case "hydrate:error": return { ...state, loading: false, error: action.error };
case "live:set": return { ...state, live: action.live, codeAgentAvailability: action.availability, selectedDevicePodId: action.selectedDevicePodId };
case "device:selected": return { ...state, selectedDevicePodId: action.devicePodId };
case "profile:set": return { ...state, providerProfile: action.profile };
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 };
case "message:trace": {
const updated = state.messages.map((message) => message.id === action.messageId ? { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, action.trace) } : message);
return { ...state, messages: updated };
}
case "message:complete": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message), codeAgentAvailability: action.availability ?? state.codeAgentAvailability, workspace: action.workspace ?? state.workspace };
case "message:fail": return { ...state, messages: replaceMessage(state.messages, action.messageId, action.message) };
case "chat:done": return { ...state, chatPending: false, currentRequest: null };
case "conversation:select": return { ...state, workspace: action.workspace ?? state.workspace, messages: action.conversation?.messages ?? [], error: null };
case "conversation:list": return { ...state, conversations: action.conversations };
case "conversation:clear": return { ...state, messages: [], currentRequest: null, chatPending: false };
}
}
function replaceMessage(messages: ChatMessage[], messageId: string, replacement: ChatMessage): ChatMessage[] {
return messages.map((message) => message.id === messageId ? replacement : message);
}
function composerFromState(state: WorkbenchState, activeConversationId: string | null): ComposerState {
const activeTraceId = firstNonEmptyString(state.currentRequest?.traceId, state.workspace?.activeTraceId, state.workspace?.workspace?.activeTraceId);
const sessionStatus = firstNonEmptyString(state.messages.at(-1)?.status, state.workspace?.workspace?.sessionStatus);
const canSteer = Boolean(activeTraceId && state.chatPending && sessionStatus !== "completed" && sessionStatus !== "failed");
if (canSteer) {
return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId };
}
if (!activeConversationId && !state.workspace?.workspaceId) {
return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null };
}
return { disabled: false, disabledReason: null, submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null };
}
function availabilityFromLive(live: LiveSurface): CodeAgentAvailability | null {
const candidates = [live.healthLive.data?.codeAgent, live.healthLive.data?.availability, live.health.data?.codeAgent, live.restIndex.data?.codeAgent];
return candidates.find((item): item is CodeAgentAvailability => Boolean(item && typeof item === "object")) ?? null;
}
function conversationFromTab(tab: SessionTab): ConversationRecord {
return { conversationId: tab.conversationId ?? tab.key, sessionId: tab.sessionId, threadId: tab.threadId, status: tab.status, messages: [] };
}
function readStoredString(key: string): string | null {
try { return nonEmptyString(window.localStorage.getItem(key)); } catch { return null; }
}
function readStoredNumber(key: string, fallback: number, min: number, max: number): number {
const value = Number(readStoredString(key));
if (!Number.isFinite(value)) return fallback;
return Math.min(Math.max(Math.trunc(value), min), max);
}
function readProviderProfile(): ProviderProfile {
const value = readStoredString("hwlab.workbench.codeAgentProviderProfile.v1");
return value === "codex-api" || value === "minimax-m3" || value === "deepseek" ? value : "deepseek";
}
export function devicePodsFromLive(live: LiveSurface | null): DevicePodItem[] {
const payload = live?.devicePods.data;
return payload?.devicePods ?? payload?.items ?? [];
}
function devicePodsFromResult(result: ApiResult<DevicePodListResponse>): DevicePodItem[] {
return result.data?.devicePods ?? result.data?.items ?? [];
}
function selectVisibleDevicePodId(payload: DevicePodListResponse | null, pods: DevicePodItem[], requestedDevicePodId: string): string {
const payloadSelected = nonEmptyString(payload?.selectedDevicePodId);
@@ -371,3 +317,4 @@ function selectVisibleDevicePodId(payload: DevicePodListResponse | null, pods: D
if (requested && requestedVisible) return requested;
return payloadSelected ?? pods[0]?.devicePodId ?? "";
}
export { devicePodsFromLive } from "./workbench-reducer";
@@ -104,6 +104,13 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.message-trace-events { min-height: 0; max-height: 360px; overflow: auto; }
.message-trace-empty { color: var(--muted); font-size: 12px; padding: 10px; }
.message-trace-last { color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.message-actions { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 6px 8px; border-top: 1px dashed var(--line); flex-wrap: wrap; }
.message-action { border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 4px 10px; font-size: 12px; font-weight: 700; cursor: pointer; }
.message-action:hover, .message-action:focus-visible { border-color: var(--accent); outline: 0; }
.message-action-cancel { color: var(--bad); border-color: var(--bad); }
.message-action-retry { color: var(--accent); border-color: var(--accent); }
.message-action-trace { color: var(--source); border-color: var(--source); }
.message-action-status { color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.conversation-list { min-height: 0; display: grid; align-content: start; gap: 12px; overflow: auto; padding-right: 4px; }
.message-card { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; }
.message-head { display: flex; justify-content: space-between; gap: 10px; }
+2
View File
@@ -155,6 +155,8 @@ export interface ChatMessage {
sessionId?: string | null;
threadId?: string | null;
retryOf?: string | null;
retryInput?: string | null;
traceReplayStatus?: string | null;
runnerTrace?: RunnerTrace | null;
traceEvents?: TraceEvent[];
error?: {