867f8dd751
Fixes HWLAB #775 round-1 regression vs pre-React `app.ts` / `app-conversation.ts` / `app-device-pod.ts` / `app-trace.ts` / `code-agent-status.ts`. This PR lays the foundation for rounds 2-7 by restoring three of the three explicitly named regressions: 1. **Inactivity timeout** (the "应该是无活动超时,而非全超时" line) - `services/api/client.ts` `fetchJson` / `fetchText` now accept `activityRef`; an internal `schedule()` re-evaluates `remainingMs = timeoutMs - (now - lastActivityAt)` every 1s, so long-running fetches stay alive while upstream keeps emitting trace events. The AbortError now distinguishes "总超时" vs "无活动超时(idle Ns;waitingFor=…;lastEventLabel=…)". - Mirrors pre-React `app-device-pod.ts:1219-1293` `fetchJson` contract. 2. **Code Agent status 23 rows** (the "裸 JSON 出现" line) - New `state/code-agent-status.ts` (`classifyCodeAgentStatusSummary`, `codeAgentRuntimePathFromMessage`, `codeAgentSummaryRows`) lifts the 23-row pre-React `codeAgentSummaryRows` into React; rows cover codeAgent.status, provider/mode/backend, capabilityLevel, session, sessionId/status, 会话提示, workspace, sandbox, runnerKind, protocol, implementationType, providerTrace.{command,terminalStatus, failureKind}, 运行路径语义, toolCalls, skills, conversation facts, runnerTrace, readiness blockers, last traceId, 当前部署 revision. - ConversationPanel's `<CodeAgentSummary>` now renders the 23 rows through `codeAgentSummaryRows(summary)`. Blockers render through a collapsible "展开 readiness 原始 JSON" disclosure so unmodeled blockers stay inspectable without dumping bare JSON inline. 3. **Rich trace panel** (the "Trace 过程事件实时滚动,事件渲染等全丢了" line) - New `components/conversation/MessageTracePanel.tsx` replaces `ConversationPanel`'s inline `<TracePanel>`: 32-row tail (was 16) with `traceUiKey` storage, default-open per status, toolbar with `count / follow / jump-to-bottom / lastEventLabel / last ts / short traceId`, per-row tone (`tone-border-<tone>` from `event.status`), follow-on-scroll, follow-on-`pre` body with markdown harden hook, "等待后端事件" placeholder when events is empty, and `markTraceScrollIntent`-equivalent scroll handler. - Pre-React `app-conv.ts:1337-1500` semantics are now rendered through the same `traceUiKey` storage and a follow/pause toggle. 4. **Beijing time formatting** (the "formatBeijingTime" regression) - `utils.ts` adds `formatBeijingTime(value, {withSeconds, includeYear})` using a shared `Intl.DateTimeFormat` Asia/Shanghai formatter; the pre-existing `formatTimestamp` now delegates to it. - `MessageCard` and `MessageTracePanel` now render `formatBeijingTime` in `withSeconds: true` mode so users see `2026-06-03 19:56:20` not raw ISO `2026-06-03T11:56:20.731Z`. 5. **`api.gateDiagnostics` and `api.cancelAgentMessage` clients** are now declared alongside the existing `sendAgentMessage` / `getAgentChatResult` so round 6 can wire `GateView` and round 3 can wire 取消当前请求 without another client surface churn. ## Verification - `cd web/hwlab-cloud-web && bun run check` passes (Vite build + 2/2 dist freshness test pass + 12 fresh dist files) - `bun run scripts/tsc-check.ts` passes (strict React TSX, 0 explicit any) - `bun test` 2 pass / 0 fail - `bun run build` 9 dist files verified fresh; `app.js` grew 198671 → 213154 bytes; `index.css` grew 13483 → 15324 bytes - new `app.js` contains: `activityRef`, `inactivityDetail`, `MessageTracePanel`, `codeAgentSummaryRows`, `classifyCodeAgentStatusSummary`, `formatBeijingTime` - new `index.css` contains: `.message-trace-toolbar`, `.message-trace-events`, `.message-trace-list`, `.message-trace-row`, `.message-trace-body`, `.message-trace-empty`, `.message-trace-last`, `.message-trace-time`, `.message-trace-tone`, `.message-trace-label`, `.code-agent-summary-rows`, `.code-agent-summary-row`, `.code-agent-summary-key`, `.code-agent-summary-value`, `.code-agent-summary-raw` - `web/hwlab-cloud-web/src/state/code-agent-status.ts` 398 lines, the other new/edited files are also within the 400-line frontend guard. Refs: pikasTech/HWLAB#775 (round 1 of 7) Co-authored-by: HWLAB <ci@hwlab.local>
136 lines
5.9 KiB
TypeScript
136 lines
5.9 KiB
TypeScript
import type { ReactElement } from "react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import type { RunnerTrace, TraceEvent } from "../../types/domain";
|
|
import { formatBeijingTime, formatDuration, shortToken, toneClass } from "../../utils";
|
|
import { summarizeLastEvent } from "../../state/code-agent-status";
|
|
|
|
interface MessageTracePanelProps {
|
|
trace: RunnerTrace;
|
|
defaultOpen?: boolean;
|
|
storageKey?: string;
|
|
}
|
|
|
|
const MAX_VISIBLE_ROWS = 32;
|
|
|
|
interface DisplayRow {
|
|
key: string;
|
|
label: string;
|
|
body: string;
|
|
tone: string;
|
|
ts: string | null;
|
|
}
|
|
|
|
export function MessageTracePanel({ trace, defaultOpen, storageKey }: MessageTracePanelProps): ReactElement {
|
|
const storedOpen = readStoredOpen(storageKey, defaultOpen ?? defaultTraceDetailsOpen(trace));
|
|
const [open, setOpen] = useState<boolean>(storedOpen);
|
|
const [follow, setFollow] = useState<boolean>(true);
|
|
const listRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (storageKey) {
|
|
try { window.localStorage.setItem(storageKey, open ? "1" : "0"); } catch { /* ignore */ }
|
|
}
|
|
}, [open, storageKey]);
|
|
|
|
useEffect(() => {
|
|
if (follow && listRef.current) {
|
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
}
|
|
}, [trace, follow, open]);
|
|
|
|
const events = Array.isArray(trace.events) ? trace.events : [];
|
|
const last = summarizeLastEvent(trace);
|
|
const rows = useMemo<DisplayRow[]>(() => displayRows(events), [events]);
|
|
|
|
return (
|
|
<details className={`message-trace tone-border-${toneClass(last.tone)}`} data-trace-ui-key={trace.traceId ?? "trace"} open={open} onToggle={(event) => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
|
<summary className="message-meta">
|
|
<span>运行 trace · {trace.status ?? "unknown"} · {rows.length || trace.eventCount || 0} events</span>
|
|
<span className={`message-trace-last tone-${toneClass(last.tone)}`}>last · {last.label}</span>
|
|
{last.ts ? <span className="message-trace-time">{formatBeijingTime(last.ts, { withSeconds: true })}</span> : null}
|
|
</summary>
|
|
<div className="message-trace-toolbar" role="toolbar" aria-label="trace toolbar">
|
|
<span className="message-trace-count">{traceCountText(trace, events.length, rows.length)}</span>
|
|
<button type="button" className="command-button secondary" onClick={() => setFollow((value) => !value)}>{follow ? "跟随" : "已暂停"}</button>
|
|
<button type="button" className="command-button secondary" onClick={() => { setFollow(true); if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight; }}>跳到底部</button>
|
|
<span className="message-trace-toolbar-spacer" />
|
|
<span className="message-trace-storage-key" title={storageKey ?? "no storage key"}>{shortToken(trace.traceId, 10)}</span>
|
|
</div>
|
|
<div
|
|
className="message-trace-events"
|
|
data-trace-ui-key={trace.traceId ?? "trace"}
|
|
data-trace-status={trace.status ?? "source"}
|
|
ref={listRef}
|
|
onScroll={() => {
|
|
const node = listRef.current;
|
|
if (!node) return;
|
|
if (node.scrollHeight - node.clientHeight - node.scrollTop > 24) setFollow(false);
|
|
}}
|
|
>
|
|
{rows.length === 0 ? (
|
|
<p className="message-trace-empty">trace={trace.traceId ?? "pending"};等待后端事件。</p>
|
|
) : (
|
|
<ol className="message-trace-list">
|
|
{rows.map((row) => (
|
|
<li key={row.key} className={`message-trace-row tone-border-${toneClass(row.tone)}`} data-trace-row-id={row.key}>
|
|
<header className="message-trace-line">
|
|
<span className={`message-trace-tone tone-${toneClass(row.tone)}`} aria-hidden="true">●</span>
|
|
<span className="message-trace-label">{row.label}</span>
|
|
{row.ts ? <span className="message-trace-time">{formatBeijingTime(row.ts, { withSeconds: true })}</span> : null}
|
|
</header>
|
|
<pre className="message-trace-body message-copy-markdown">{row.body}</pre>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
function displayRows(events: TraceEvent[]): DisplayRow[] {
|
|
if (events.length === 0) return [];
|
|
const tail = events.slice(-MAX_VISIBLE_ROWS);
|
|
return tail.map((event, index) => {
|
|
const key = `${event.traceId ?? "event"}-${event.seq ?? index}-${event.ts ?? index}`;
|
|
const label = String(event.label ?? event.kind ?? event.type ?? "event");
|
|
const body = eventSummary(event);
|
|
const tone = String(event.status ?? event.level ?? "source");
|
|
const ts = event.ts ?? null;
|
|
return { key, label, body, tone, ts };
|
|
});
|
|
}
|
|
|
|
function eventSummary(event: TraceEvent): string {
|
|
if (typeof event.text === "string" && event.text.trim()) return event.text;
|
|
if (typeof event.message === "string" && event.message.trim()) return event.message;
|
|
if (event.payload && typeof event.payload === "object") {
|
|
try { return JSON.stringify(event.payload, null, 2); } catch { return String(event.payload); }
|
|
}
|
|
if (event.evidence && Array.isArray(event.evidence)) return event.evidence.join("\n");
|
|
return "(empty event)";
|
|
}
|
|
|
|
function traceCountText(trace: RunnerTrace, events: number, rows: number): string {
|
|
const rawTotal = Number.isInteger(trace.eventCount) ? trace.eventCount : events;
|
|
if (rawTotal === events) return `${rows} 条事件`;
|
|
return `${rows}/${rawTotal} 条事件(已折叠)`;
|
|
}
|
|
|
|
function defaultTraceDetailsOpen(trace: RunnerTrace): boolean {
|
|
return ["running", "completed", "source", "failed", "timeout", "canceled", "error"].includes(String(trace.status ?? "").toLowerCase());
|
|
}
|
|
|
|
function readStoredOpen(key: string | undefined, fallback: boolean): boolean {
|
|
if (!key || typeof window === "undefined") return fallback;
|
|
try {
|
|
const value = window.localStorage.getItem(key);
|
|
if (value === "1") return true;
|
|
if (value === "0") return false;
|
|
} catch { /* ignore */ }
|
|
return fallback;
|
|
}
|
|
|
|
export { formatDuration };
|