fix(web): v0.2 round-7 workbench: message pending/session-continuity/runtime-path context panels (HWLAB #775 round 4) (#785)
Continues HWLAB #775 round 4 by restoring the three message-level context panels that the React migration dropped: - `messagePendingContextPanel` (`app-conv.ts:1228`) - `messageSessionContinuityPanel` (`app-conv.ts:1183`) - `messageRuntimePathPanel` (`app-conv.ts:1268`) ## 修复对照 | 类别 | 迁移前 (`893f46bc`) | 迁移后 (round 4) | 状态 | |---|---|---|---| | **Pending context 面板** | `app-conv.ts:1228` `messagePendingContextPanel`(traceId/conversation/session/thread/activityTimeout) | `components/conversation/MessagePendingContext.tsx`:grid 2 列 + activity timeout 用 `formatDuration` | ✅ 对齐 | | **Session continuity 面板** | `app-conv.ts:1183` `messageSessionContinuityPanel`(conversation/session/thread/lifecycle/retryOf/missing/changed) | `components/conversation/MessageSessionContinuity.tsx`:grid 2 列 + lifecycleHint/lastEvent | ✅ 对齐 | | **Runtime path 面板** | `app-conv.ts:1268` `messageRuntimePathPanel`(rows + missingFields + compact details) | `components/conversation/MessageRuntimePath.tsx`:复用 round-1 `codeAgentRuntimePathFromMessage`,加 missingFields 行 | ✅ 对齐 | ## 改动 - 新增 `web/hwlab-cloud-web/src/components/conversation/MessagePendingContext.tsx`(43 行) - 新增 `web/hwlab-cloud-web/src/components/conversation/MessageRuntimePath.tsx`(37 行) - 新增 `web/hwlab-cloud-web/src/components/conversation/MessageSessionContinuity.tsx`(58 行) - `web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx` — `MessageCard` 顺序:title → body → pending/continuity/runtime → footer → trace → actions - `web/hwlab-cloud-web/src/App.tsx` — `<ConversationPanel>` 接 `codeAgentTimeoutMs` - `web/hwlab-cloud-web/src/styles/workbench.css` — `.message-pending-context` / `.message-session-context` / `.message-runtime-path` 共享 grid 样式 ## 验收 - `bun run check` 通过(Vite build + 2/2 dist freshness test pass) - `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` 221256 → 226176 bytes;`index.css` 16041 → 17152 bytes - dist `app.js` 含 `message-pending-context` / `message-session-context` / `message-runtime-path` / `message-pending-grid` / `message-session-grid` / `message-runtime-grid` - 所有改动文件 < 400 行前端 guard Refs: pikasTech/HWLAB#775 (round 4 of 7) Co-authored-by: HWLAB <ci@hwlab.local>
This commit is contained in:
@@ -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 ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null}
|
||||
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} 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}
|
||||
|
||||
@@ -8,6 +8,9 @@ import { codeAgentSummaryRows, classifyCodeAgentStatusSummary } from "../../stat
|
||||
import { StateTag } from "../shared/StateTag";
|
||||
import { MessageTracePanel } from "./MessageTracePanel";
|
||||
import { MessageActions } from "./MessageActions";
|
||||
import { MessagePendingContext } from "./MessagePendingContext";
|
||||
import { MessageRuntimePath } from "./MessageRuntimePath";
|
||||
import { MessageSessionContinuity } from "./MessageSessionContinuity";
|
||||
|
||||
interface ConversationPanelProps {
|
||||
messages: ChatMessage[];
|
||||
@@ -20,7 +23,7 @@ interface ConversationPanelProps {
|
||||
onReplayTrace(messageId: string): void;
|
||||
}
|
||||
|
||||
export function ConversationPanel({ messages, availability, live, chatPending, onCopySession, onCancelMessage, onRetryMessage, onReplayTrace }: ConversationPanelProps): ReactElement {
|
||||
export function ConversationPanel({ messages, availability, live, chatPending, codeAgentTimeoutMs, onCopySession, onCancelMessage, onRetryMessage, onReplayTrace }: ConversationPanelProps & { codeAgentTimeoutMs: number }): ReactElement {
|
||||
const intro = useMemo<ChatMessage[]>(() => [
|
||||
{
|
||||
id: "intro-mode",
|
||||
@@ -56,7 +59,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} onCancel={() => onCancelMessage(message.id)} onRetry={() => onRetryMessage(message.id)} onReplayTrace={() => onReplayTrace(message.id)} />)}
|
||||
{[...intro, ...messages].map((message) => <MessageCard key={message.id} message={message} codeAgentTimeoutMs={codeAgentTimeoutMs} onCancel={() => onCancelMessage(message.id)} onRetry={() => onRetryMessage(message.id)} onReplayTrace={() => onReplayTrace(message.id)} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +101,7 @@ function CodeAgentSummary({ availability, live, latestMessage }: { availability:
|
||||
);
|
||||
}
|
||||
|
||||
function MessageCard({ message, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement {
|
||||
function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement {
|
||||
const html = renderMessageMarkdown(message.text);
|
||||
const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`;
|
||||
return (
|
||||
@@ -111,6 +114,9 @@ function MessageCard({ message, onCancel, onRetry, onReplayTrace }: { message: C
|
||||
<StateTag tone={toneClass(message.status)}>{statusLabel(message.status)}</StateTag>
|
||||
</header>
|
||||
<div className="message-body" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
{message.role === "agent" && message.status === "running" ? <MessagePendingContext message={message} codeAgentTimeoutMs={codeAgentTimeoutMs} /> : null}
|
||||
{message.role === "agent" && message.status !== "running" ? <MessageSessionContinuity message={message} /> : null}
|
||||
{message.role === "agent" ? <MessageRuntimePath message={message} /> : null}
|
||||
<footer className="message-meta">
|
||||
<span>trace {shortToken(message.traceId, 10)}</span>
|
||||
<span>session {shortToken(message.sessionId, 10)}</span>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import type { ChatMessage } from "../../types/domain";
|
||||
import { formatBeijingTime, formatDuration, shortToken, toneClass } from "../../utils";
|
||||
|
||||
interface MessagePendingContextProps {
|
||||
message: ChatMessage;
|
||||
codeAgentTimeoutMs: number;
|
||||
}
|
||||
|
||||
export function MessagePendingContext({ message, codeAgentTimeoutMs }: MessagePendingContextProps): ReactElement | null {
|
||||
if (message.status !== "running") return null;
|
||||
const fields: Array<[string, string | null | undefined, boolean]> = [
|
||||
["状态", "处理中", false],
|
||||
["traceId", typeof message.traceId === "string" ? message.traceId : null, true],
|
||||
["conversation", typeof message.conversationId === "string" ? message.conversationId : null, true],
|
||||
["session", message.sessionId ?? message.runnerTrace?.sessionId ?? "等待后端分配", true],
|
||||
["thread", message.threadId ?? message.runnerTrace?.threadId ?? "等待后端分配", false],
|
||||
["sessionStatus", typeof message.runnerTrace?.sessionStatus === "string" ? message.runnerTrace.sessionStatus : null, false],
|
||||
["lifecycle", typeof message.runnerTrace?.sessionLifecycleStatus === "string" ? message.runnerTrace.sessionLifecycleStatus : null, false],
|
||||
["activityTimeout", `${formatDuration(codeAgentTimeoutMs)} 无新事件`, false]
|
||||
];
|
||||
return (
|
||||
<section className="message-pending-context tone-border-pending" data-message-pending-context>
|
||||
<div className="message-pending-head">
|
||||
<span className={`state-tag tone-${toneClass("pending")}`} data-pending-tag>处理中</span>
|
||||
<span className="message-pending-summary">{message.runnerTrace?.waitingFor ? `等待 ${message.runnerTrace.waitingFor}` : "等待后端事件"}</span>
|
||||
</div>
|
||||
<div className="message-pending-grid" role="list">
|
||||
{fields.map(([label, value, copyable]) => {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
return (
|
||||
<div key={label} className="message-pending-row" role="listitem">
|
||||
<span className="message-pending-key">{label}</span>
|
||||
<span className="message-pending-value mono">{copyable ? shortToken(value, 32) : value}</span>
|
||||
<span className="message-pending-time">{formatBeijingTime(message.updatedAt ?? message.createdAt, { withSeconds: true })}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import type { ChatMessage } from "../../types/domain";
|
||||
import { codeAgentRuntimePathFromMessage } from "../../state/code-agent-status";
|
||||
import { toneClass } from "../../utils";
|
||||
|
||||
interface MessageRuntimePathProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
export function MessageRuntimePath({ message }: MessageRuntimePathProps): ReactElement | null {
|
||||
if (message.role !== "agent") return null;
|
||||
const runtimePath = codeAgentRuntimePathFromMessage(message as unknown as Record<string, unknown>);
|
||||
if (!runtimePath) return null;
|
||||
return (
|
||||
<details className={`message-runtime-path tone-border-${toneClass(runtimePath.tone)}`} data-message-runtime-path>
|
||||
<summary>
|
||||
<span className={`state-tag tone-${toneClass(runtimePath.tone)}`} data-runtime-path-tag>{runtimePath.label}</span>
|
||||
<span className="message-runtime-summary">{runtimePath.summary}</span>
|
||||
</summary>
|
||||
<div className="message-runtime-grid" role="list">
|
||||
{runtimePath.rows.map((row) => (
|
||||
<div key={row.label} className={`message-runtime-row${row.value === "未观测" || row.value === "字段缺失:证据不足" ? " message-runtime-row-missing" : ""}`} role="listitem">
|
||||
<span className="message-runtime-key">{row.label}</span>
|
||||
<span className="message-runtime-value mono">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{runtimePath.missingFields.length > 0 ? (
|
||||
<div className="message-runtime-row message-runtime-row-missing message-runtime-row-wide" role="listitem">
|
||||
<span className="message-runtime-key">缺失字段</span>
|
||||
<span className="message-runtime-value mono">{runtimePath.missingFields.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import type { ChatMessage } from "../../types/domain";
|
||||
import { nonEmptyString } from "../../utils";
|
||||
import { formatBeijingTime, shortToken, toneClass } from "../../utils";
|
||||
|
||||
interface MessageSessionContinuityProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
interface ContinuityField {
|
||||
label: string;
|
||||
value: string | null;
|
||||
copyable: boolean;
|
||||
}
|
||||
|
||||
export function MessageSessionContinuity({ message }: MessageSessionContinuityProps): ReactElement | null {
|
||||
if (message.role !== "agent" || message.status === "running") return null;
|
||||
const sessionLifecycle = pickString(message.runnerTrace?.sessionLifecycleStatus);
|
||||
const hint = pickString(message.runnerTrace?.sessionLifecycleHint);
|
||||
const lastEventLabel = pickString(message.runnerTrace?.lastEventLabel);
|
||||
const fields: ContinuityField[] = [
|
||||
{ label: "状态", value: statusLabel(message.status), copyable: false },
|
||||
{ label: "conversation", value: message.conversationId ?? "未确认", copyable: true },
|
||||
{ label: "session", value: message.sessionId ?? "未确认", copyable: true },
|
||||
{ label: "thread", value: message.threadId ?? "未确认", copyable: true },
|
||||
{ label: "lifecycle", value: sessionLifecycle, copyable: false },
|
||||
{ label: "提示", value: hint, copyable: false },
|
||||
{ label: "traceId", value: message.traceId ?? "未确认", copyable: true },
|
||||
{ label: "lastEvent", value: lastEventLabel, copyable: false }
|
||||
].filter((field): field is ContinuityField => Boolean(field.value && field.value !== "未确认"));
|
||||
if (fields.length === 0) return null;
|
||||
return (
|
||||
<details className={`message-session-context tone-border-${toneClass(message.status)}`} data-message-session-continuity>
|
||||
<summary>
|
||||
<span className={`state-tag tone-${toneClass(message.status)}`} data-session-continuity-tag>会话 {statusLabel(message.status)}</span>
|
||||
<span className="message-session-summary">{hint ?? lastEventLabel ?? "会话上下文已记录"}</span>
|
||||
</summary>
|
||||
<div className="message-session-grid" role="list">
|
||||
{fields.map((field) => (
|
||||
<div key={field.label} className="message-session-row" role="listitem">
|
||||
<span className="message-session-key">{field.label}</span>
|
||||
<span className="message-session-value mono">{field.copyable ? shortToken(field.value, 32) : field.value}</span>
|
||||
<span className="message-session-time">{formatBeijingTime(message.updatedAt ?? message.createdAt, { withSeconds: true })}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = { completed: "完成", failed: "失败", blocked: "阻塞", timeout: "超时", canceled: "已取消", source: "SOURCE" };
|
||||
return map[status] ?? status ?? "未知";
|
||||
}
|
||||
function pickString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
@@ -111,6 +111,18 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.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; }
|
||||
.message-pending-context { display: grid; gap: 6px; margin-top: 10px; padding: 8px 10px; border-radius: 6px; background: #fbfcf8; }
|
||||
.message-pending-head { display: flex; align-items: center; gap: 8px; }
|
||||
.message-pending-summary { color: var(--muted); font-size: 12px; }
|
||||
.message-pending-grid, .message-session-grid, .message-runtime-grid { display: grid; gap: 4px 12px; grid-template-columns: minmax(120px, max-content) 1fr; align-items: baseline; }
|
||||
.message-pending-row, .message-session-row, .message-runtime-row { display: contents; }
|
||||
.message-pending-key, .message-session-key, .message-runtime-key { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.message-pending-value, .message-session-value, .message-runtime-value { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.message-pending-time, .message-session-time { color: var(--muted); font-size: 12px; grid-column: 1 / -1; }
|
||||
.message-runtime-row-missing .message-runtime-value { color: var(--bad); }
|
||||
.message-runtime-row-wide { grid-column: 1 / -1; }
|
||||
.message-runtime-summary { color: var(--muted); font-size: 12px; }
|
||||
.message-session-summary { color: var(--muted); font-size: 12px; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user