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:
Lyon
2026-06-03 23:26:09 +08:00
committed by GitHub
parent dbd09adb8d
commit 527c5b0dd5
6 changed files with 161 additions and 4 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 ?? "")} 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; }