fix(web): v0.2 round-4 workbench: inactivity-timeout fetchJson + 23-row Code Agent status + rich trace panel (HWLAB #775 round 1) (#777)

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>
This commit is contained in:
Lyon
2026-06-03 21:49:41 +08:00
committed by GitHub
parent 6341015d3b
commit 867f8dd751
7 changed files with 789 additions and 44 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} 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 ?? "")} /> : null}
{route === "skills" ? <SkillsView /> : null}
{route === "gate" ? <GateView /> : null}
{route === "help" ? <HelpView help={help} /> : null}
@@ -2,18 +2,21 @@ import type { ReactElement } from "react";
import { useMemo } from "react";
import { renderMessageMarkdown } from "../../services/markdown/render";
import type { ChatMessage, CodeAgentAvailability } from "../../types/domain";
import { formatTimestamp, jsonPreview, shortToken, toneClass } from "../../utils";
import type { ChatMessage, CodeAgentAvailability, LiveSurface } from "../../types/domain";
import { formatBeijingTime, jsonPreview, shortToken, toneClass } from "../../utils";
import { codeAgentSummaryRows, classifyCodeAgentStatusSummary } from "../../state/code-agent-status";
import { StateTag } from "../shared/StateTag";
import { MessageTracePanel } from "./MessageTracePanel";
interface ConversationPanelProps {
messages: ChatMessage[];
availability: CodeAgentAvailability | null;
live: LiveSurface | null;
chatPending: boolean;
onCopySession(): void;
}
export function ConversationPanel({ messages, availability, chatPending, onCopySession }: ConversationPanelProps): ReactElement {
export function ConversationPanel({ messages, availability, live, chatPending, onCopySession }: ConversationPanelProps): ReactElement {
const intro = useMemo<ChatMessage[]>(() => [
{
id: "intro-mode",
@@ -47,7 +50,7 @@ export function ConversationPanel({ messages, availability, chatPending, onCopyS
<StateTag id="agent-chat-status" tone={chatPending ? "pending" : "source"}>{chatPending ? "处理中" : "等待输入"}</StateTag>
</div>
</div>
<CodeAgentSummary availability={availability} />
<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} />)}
</div>
@@ -57,18 +60,35 @@ export function ConversationPanel({ messages, availability, chatPending, onCopyS
);
}
function CodeAgentSummary({ availability }: { availability: CodeAgentAvailability | null }): ReactElement {
const tone = availability?.ready === true ? "ok" : toneClass(availability?.status);
function latestAgentMessage(messages: ChatMessage[]): Record<string, unknown> | null {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role === "agent") return { ...message };
}
return null;
}
function CodeAgentSummary({ availability, live, latestMessage }: { availability: CodeAgentAvailability | null; live: LiveSurface | null; latestMessage: Record<string, unknown> | null }): ReactElement {
const summary = useMemo(() => classifyCodeAgentStatusSummary({ availability, live, latestMessage }), [availability, live, latestMessage]);
const rows = useMemo(() => codeAgentSummaryRows(summary), [summary]);
return (
<details className={`code-agent-summary tone-border-${tone}`} id="code-agent-summary">
<details className={`code-agent-summary tone-border-${toneClass(summary.tone)}`} id="code-agent-summary" data-code-agent-status-kind={summary.kind}>
<summary>
<span className={`code-agent-summary-icon tone-${tone}`} id="code-agent-summary-icon" aria-hidden="true"></span>
<strong className={`code-agent-summary-label tone-${tone}`} id="code-agent-summary-label">{availability?.ready === true ? "长会话可用" : "探测中"}</strong>
<span className="code-agent-summary-capability" id="code-agent-summary-capability">{availability?.capabilityLevel ?? "未观测"}</span>
<span className="code-agent-summary-trace" id="code-agent-summary-trace">{availability?.session && typeof availability.session === "object" ? "session 已观测" : "trace 未观测"}</span>
<span className={`code-agent-summary-icon tone-${toneClass(summary.tone)}`} id="code-agent-summary-icon" aria-hidden="true">{summary.icon}</span>
<strong className={`code-agent-summary-label tone-${toneClass(summary.tone)}`} id="code-agent-summary-label">{summary.label}</strong>
<span className="code-agent-summary-capability" id="code-agent-summary-capability">{summary.capabilityLevel}</span>
<span className="code-agent-summary-trace" id="code-agent-summary-trace">trace {summary.lastTraceId}</span>
</summary>
<div className="code-agent-summary-detail" id="code-agent-summary-detail">
<pre>{jsonPreview(availability ?? { status: "unverified" }, 1200)}</pre>
<div className="code-agent-summary-rows" role="table" aria-label="Code Agent 状态详情">
{rows.map((row) => (
<div className="code-agent-summary-row" role="row" key={row.key}>
<span className="code-agent-summary-key" role="rowheader">{row.key}</span>
<span className={`code-agent-summary-value tone-${toneClass(row.tone)}`} role="cell">{row.value}</span>
</div>
))}
</div>
{summary.readinessBlockers.length > 0 ? <details className="code-agent-summary-raw"><summary> readiness JSON</summary><pre>{jsonPreview(availability ?? { status: "unverified" }, 1200)}</pre></details> : null}
</div>
</details>
);
@@ -76,6 +96,7 @@ function CodeAgentSummary({ availability }: { availability: CodeAgentAvailabilit
function MessageCard({ message }: { message: ChatMessage }): ReactElement {
const html = renderMessageMarkdown(message.text);
const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`;
return (
<article className={`message-card message-${message.role} tone-border-${toneClass(message.status)}`} data-message-status={message.status}>
<header className="message-head">
@@ -89,25 +110,13 @@ function MessageCard({ message }: { message: ChatMessage }): ReactElement {
<footer className="message-meta">
<span>trace {shortToken(message.traceId, 10)}</span>
<span>session {shortToken(message.sessionId, 10)}</span>
<span>{formatTimestamp(message.updatedAt ?? message.createdAt)}</span>
<span>{formatBeijingTime(message.updatedAt ?? message.createdAt, { withSeconds: true })}</span>
</footer>
{message.runnerTrace ? <TracePanel trace={message.runnerTrace} /> : null}
{message.runnerTrace ? <MessageTracePanel trace={message.runnerTrace} storageKey={traceKey} /> : null}
</article>
);
}
function TracePanel({ trace }: { trace: NonNullable<ChatMessage["runnerTrace"]> }): ReactElement {
const events = trace.events ?? [];
return (
<details className="message-trace" data-trace-ui-key={trace.traceId ?? "trace"}>
<summary> trace · {trace.status ?? "unknown"} · {events.length || trace.eventCount || 0} events</summary>
<div className="message-trace-events" data-trace-ui-key={trace.traceId ?? "trace"} data-trace-status={trace.status ?? "source"}>
{events.slice(-16).map((event, index) => <pre className="message-trace-row" data-trace-row-id={`${index}`} key={`${event.kind ?? event.type ?? "event"}-${index}`}>{jsonPreview(event, 500)}</pre>)}
</div>
</details>
);
}
function statusLabel(status: string): string {
const map: Record<string, string> = { source: "SOURCE", sent: "已发送", running: "处理中", completed: "完成", failed: "失败", blocked: "阻塞", timeout: "超时", canceled: "已取消" };
return map[status] ?? status;
@@ -0,0 +1,135 @@
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 };
+149 -10
View File
@@ -13,14 +13,68 @@ import type {
WorkspaceRecord
} from "../../types/domain";
export interface ActivityRef {
lastActivityAt: number;
lastActivityIso?: string;
waitingFor?: string | null;
lastEventLabel?: string | null;
}
export type ActivityRefSource = (() => ActivityRef | null) | null;
export interface ApiRequestOptions extends RequestInit {
timeoutMs?: number;
timeoutName?: string;
/**
* Inactivity-timeout reference. When supplied, the abort is re-evaluated
* against `Date.now() - lastActivityAt`, so a long-running fetch stays
* alive as long as upstream keeps emitting trace events. Mirrors the
* pre-React `app-device-pod.ts:fetchJson` activityRef contract so the
* Code Agent / trace / probe endpoints share the same model.
*/
activityRef?: ActivityRef | ActivityRefSource;
}
const ACTIVITY_RESCHEDULER_MS = 1000;
const DEFAULT_TIMEOUT_MS = 4500;
export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<T>> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 4500);
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const activitySource = resolveActivitySource(options.activityRef);
let timer: number | null = null;
const inactivityRef: { current: { idleMs: number; lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null } | null } = { current: null };
const startedAt = Date.now();
function readActivity(): { lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null } {
const fallback = { lastActivityAt: startedAt, lastActivityIso: new Date(startedAt).toISOString() };
if (!activitySource) return fallback;
const ref = activitySource();
if (!ref || !Number.isFinite(ref.lastActivityAt) || ref.lastActivityAt <= 0) return fallback;
return ref;
}
function schedule(): void {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
if (!activitySource) {
timer = window.setTimeout(() => controller.abort(), timeoutMs);
return;
}
const activity = readActivity();
const idleMs = Math.max(0, Date.now() - activity.lastActivityAt);
const remaining = timeoutMs - idleMs;
if (remaining <= 0) {
inactivityRef.current = { idleMs, lastActivityAt: activity.lastActivityAt, lastActivityIso: activity.lastActivityIso, waitingFor: activity.waitingFor, lastEventLabel: activity.lastEventLabel };
controller.abort();
return;
}
timer = window.setTimeout(schedule, Math.max(250, Math.min(remaining, ACTIVITY_RESCHEDULER_MS)));
}
schedule();
try {
const response = await fetch(path, {
...options,
@@ -40,30 +94,85 @@ export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}
error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`)
};
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
if (inactivityRef.current) {
const detail = inactivityRef.current;
const idleSeconds = Math.round(detail.idleMs / 1000);
return {
ok: false,
status: 0,
data: null,
error: `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${idleSeconds}swaitingFor=${detail.waitingFor ?? "unknown"}lastEventLabel=${detail.lastEventLabel ?? "none"}`
};
}
return {
ok: false,
status: 0,
data: null,
error: `${options.timeoutName ?? path} 超时`
};
}
return {
ok: false,
status: 0,
data: null,
error: error instanceof DOMException && error.name === "AbortError"
? `${options.timeoutName ?? path} 超时`
: error instanceof Error ? error.message : String(error)
error: error instanceof Error ? error.message : String(error)
};
} finally {
window.clearTimeout(timeout);
if (timer !== null) window.clearTimeout(timer);
}
}
function resolveActivitySource(ref: ApiRequestOptions["activityRef"]): ActivityRefSource {
if (typeof ref === "function") return ref;
if (ref && typeof ref === "object") return () => ref;
return null;
}
export async function fetchText(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<string>> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 4500);
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const activitySource = resolveActivitySource(options.activityRef);
let timer: number | null = null;
const inactivityRef: { current: { idleMs: number; lastActivityAt: number } | null } = { current: null };
const startedAt = Date.now();
function readActivity(): number {
if (!activitySource) return startedAt;
const ref = activitySource();
if (!ref || !Number.isFinite(ref.lastActivityAt) || ref.lastActivityAt <= 0) return startedAt;
return ref.lastActivityAt;
}
function schedule(): void {
if (timer !== null) window.clearTimeout(timer);
if (!activitySource) {
timer = window.setTimeout(() => controller.abort(), timeoutMs);
return;
}
const idleMs = Math.max(0, Date.now() - readActivity());
const remaining = timeoutMs - idleMs;
if (remaining <= 0) {
inactivityRef.current = { idleMs, lastActivityAt: readActivity() };
controller.abort();
return;
}
timer = window.setTimeout(schedule, Math.max(250, Math.min(remaining, ACTIVITY_RESCHEDULER_MS)));
}
schedule();
try {
const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin", signal: controller.signal });
const text = await response.text();
return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` };
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
if (inactivityRef.current) {
const detail = inactivityRef.current;
return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${Math.round(detail.idleMs / 1000)}s` };
}
return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超时` };
}
return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) };
} finally {
window.clearTimeout(timeout);
if (timer !== null) window.clearTimeout(timer);
}
}
@@ -98,11 +207,41 @@ export const api = {
conversations: (projectId: string): Promise<ApiResult<{ conversations?: ConversationRecord[] }>> => fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "conversations" }),
deleteConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }),
saveConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" }),
sendAgentMessage: (payload: Record<string, unknown>, timeoutMs: number): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent" }),
getAgentChatResult: (resultUrl: string, timeoutMs = 8000): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result" }),
steerAgentMessage: (payload: Record<string, unknown>, timeoutMs: number): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer" }),
sendAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent", activityRef }),
steerAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer", activityRef }),
getAgentChatResult: (resultUrl: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }),
cancelAgentMessage: (payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean; canceled?: boolean; error?: { code?: string; message?: string; userMessage?: string } }>> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" }),
gateDiagnostics: (): Promise<ApiResult<GateDiagnosticsResponse>> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }),
skills: (): Promise<ApiResult<SkillsResponse>> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }),
uploadSkills: (files: Array<{ relativePath: string; sizeBytes: number; contentBase64: string }>): Promise<ApiResult<{ skill?: { id?: string } }>> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ files }), timeoutMs: 60000, timeoutName: "skill upload" }),
skillTree: (skillId: string): Promise<ApiResult<SkillTreeResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }),
skillFile: (skillId: string, relativePath: string): Promise<ApiResult<SkillFileResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(relativePath)}`, { timeoutMs: 12000, timeoutName: "skill file" })
};
export interface GateDiagnosticsResponse {
serviceId?: string;
route?: string;
contractVersion?: string;
status?: string;
sourceKind?: string;
liveBackend?: boolean;
observedAt?: string;
rowCount?: number;
rowsCount?: number;
columns?: string[];
rows?: GateDiagnosticsRow[];
error?: string;
}
export interface GateDiagnosticsRow {
category?: string;
check?: string;
status?: string;
statusKey?: string;
owner?: string;
detail?: string;
evidence?: string[];
updatedAt?: string;
next?: string;
sourceKind?: string;
}
@@ -0,0 +1,398 @@
import type { AgentChatReply, AgentRunProvenance, CodeAgentAvailability, LiveSurface, RunnerTrace, TraceEvent } from "../types/domain";
import { firstNonEmptyString, jsonPreview, nonEmptyString, shortToken } from "../utils";
export interface CodeAgentStatusSummary {
kind: "long-lived-session" | "blocked" | "skill-cli-api-blocked" | "read-only-session-tools" | "stateless-one-shot" | "fallback-text-chat-only" | "degraded" | "unverified";
tone: "ok" | "warn" | "blocked" | "source" | "dev-live";
label: string;
icon: string;
provider: string;
mode: string;
backend: string;
providerModeBackend: string;
codeAgentStatus: string;
capabilityLevel: string;
sessionMode: string;
sessionId: string;
threadId: string;
sessionStatus: string;
sessionLifecycleStatus: string;
sessionLifecycleHint: string;
sessionLifecycleLabel: string;
sessionSummary: string;
sessionIdStatus: string;
workspace: string;
sandbox: string;
runnerKind: string;
protocol: string;
implementationType: string;
providerTraceCommand: string;
providerTraceTerminalStatus: string;
providerTraceFailureKind: string;
runtimePathLabel: string;
runtimePathSummary: string;
runtimePathTone: "ok" | "warn" | "blocked" | "source" | "dev-live";
toolCalls: string;
skills: string;
runnerTrace: string;
readinessBlockers: string[];
blockersLabel: string;
lastTraceId: string;
deploymentRevision: string;
fields: Record<string, string>;
}
export interface CodeAgentRuntimePath {
protocol: string;
implementationType: string;
command: string;
terminalStatus: string;
failureKind: string;
label: string;
summary: string;
tone: "ok" | "warn" | "blocked" | "source" | "dev-live";
rows: { label: string; value: string }[];
missingFields: string[];
}
const CODEX_APP_SERVER_RUNNER = "codex-app-server-stdio-runner";
const CODEX_APP_SERVER_SESSION_MODE = "codex-app-server-stdio-long-lived";
const CODEX_APP_SERVER_IMPLEMENTATION = "repo-owned-codex-app-server-stdio-session";
const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio";
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function pickString(...values: unknown[]): string | null {
for (const value of values) {
const text = asString(value);
if (text) return text;
}
return null;
}
function readField(payload: Record<string, unknown> | null, path: readonly string[]): string | null {
let cursor: unknown = payload;
for (const key of path) {
if (!cursor || typeof cursor !== "object") return null;
cursor = (cursor as Record<string, unknown>)[key];
}
return asString(cursor);
}
function safeField(value: string | null): string {
return value ?? "未观测";
}
function compactJoin(values: ReadonlyArray<string | null>, separator: string): string {
const parts = values.filter((value): value is string => Boolean(value && value !== "未观测" && value !== "字段缺失:证据不足"));
return parts.length > 0 ? parts.join(separator) : "未观测";
}
function unsafeGreenForNonReady(tone: CodeAgentStatusSummary["tone"], blockers: string[]): boolean {
return tone === "ok" && blockers.length > 0;
}
export function classifyCodeAgentStatusSummary({ availability = null, latestMessage = null, live = null }: { availability?: CodeAgentAvailability | null; latestMessage?: Record<string, unknown> | null; live?: LiveSurface | null } = {}): CodeAgentStatusSummary {
const observedAvailability = availability ?? (live?.healthLive.data?.codeAgent as CodeAgentAvailability | undefined) ?? null;
const messageRecord = latestMessage ? asRecord(latestMessage) : null;
const availabilityRecord = observedAvailability ? asRecord(observedAvailability) : null;
const messageStatus = pickString(messageRecord?.status as string | undefined, availabilityRecord?.status as string | undefined);
const blockers = readinessBlockers(availabilityRecord, messageRecord);
const classification = classifyPayload(availabilityRecord, messageRecord, blockers, messageStatus);
const safeTone = unsafeGreenForNonReady(classification.tone, blockers) ? "warn" : classification.tone;
const provider = pickString(availabilityRecord?.provider as string | undefined, observedAvailability?.provider, messageRecord?.provider as string | undefined);
const mode = pickString(availabilityRecord?.mode as string | undefined, messageRecord?.providerTrace && (messageRecord.providerTrace as Record<string, unknown>).mode as string | undefined, observedAvailability?.mode);
const backend = pickString(availabilityRecord?.backend as string | undefined, observedAvailability?.backend, messageRecord?.backend as string | undefined);
const codeAgentStatus = pickString(messageStatus, availabilityRecord?.readinessStatus as string | undefined, observedAvailability?.status);
const capabilityLevel = pickString(availabilityRecord?.capabilityLevel as string | undefined, messageRecord?.capabilityLevel as string | undefined, observedAvailability?.capabilityLevel);
const sessionMode = pickString(availabilityRecord?.sessionMode as string | undefined, messageRecord?.sessionMode as string | undefined, observedAvailability?.sessionMode);
const sessionId = pickString(messageRecord?.sessionId as string | undefined, observedAvailability?.sessionId);
const threadId = pickString(messageRecord?.threadId as string | undefined, observedAvailability?.threadId);
const sessionStatus = pickString(messageRecord?.status as string | undefined, messageRecord?.sessionStatus as string | undefined);
const lifecycle = sessionLifecycleSummary(messageStatus, blockers);
const workspace = pickString(messageRecord?.workspace as string | undefined, messageRecord?.workspaceId as string | undefined);
const sandbox = pickString(messageRecord?.sandbox as string | undefined, messageRecord?.runner && (messageRecord.runner as Record<string, unknown>).sandbox as string | undefined);
const runnerKind = pickString(messageRecord?.runnerKind as string | undefined, messageRecord?.runner && (messageRecord.runner as Record<string, unknown>).kind as string | undefined);
const runtimePath = codeAgentRuntimePathFromMessage({ role: "agent", ...(messageRecord ?? {}) });
const lastTraceId = pickString(
messageRecord?.traceId as string | undefined,
messageRecord?.lastTraceId as string | undefined
) ?? "未观测";
const deploymentRevision = currentDeploymentRevision(live);
const toolCalls = compactToolCallsSummary(messageRecord?.toolCalls);
const skills = compactSkillsSummary(messageRecord?.skills);
const runnerTrace = compactRunnerTraceSummary(messageRecord?.runnerTrace);
return {
...classification,
tone: safeTone,
provider: safeField(provider),
mode: safeField(mode),
backend: safeField(backend),
providerModeBackend: compactJoin([provider, mode, backend], " / "),
codeAgentStatus: safeField(codeAgentStatus),
capabilityLevel: safeField(capabilityLevel),
sessionMode: safeField(sessionMode),
sessionId: safeField(sessionId),
threadId: safeField(threadId),
sessionStatus: safeField(sessionStatus),
sessionLifecycleStatus: lifecycle.status,
sessionLifecycleHint: lifecycle.hint,
sessionLifecycleLabel: lifecycle.label,
sessionSummary: compactJoin([sessionMode, sessionStatus, lifecycle.status], " / "),
sessionIdStatus: compactJoin([sessionId, threadId, sessionStatus, lifecycle.status], " / "),
workspace: safeField(workspace),
sandbox: safeField(sandbox),
runnerKind: safeField(runnerKind),
protocol: runtimePath.protocol,
implementationType: runtimePath.implementationType,
providerTraceCommand: runtimePath.command,
providerTraceTerminalStatus: runtimePath.terminalStatus,
providerTraceFailureKind: runtimePath.failureKind,
runtimePathLabel: runtimePath.label,
runtimePathSummary: runtimePath.summary,
runtimePathTone: runtimePath.tone,
toolCalls,
skills,
runnerTrace,
readinessBlockers: blockers,
blockersLabel: blockers.length > 0 ? blockers.join(", ") : "无",
lastTraceId: lastTraceId || "未观测",
deploymentRevision,
fields: {
provider: safeField(provider),
mode: safeField(mode),
backend: safeField(backend),
codeAgentStatus: safeField(codeAgentStatus),
capabilityLevel: safeField(capabilityLevel),
sessionMode: safeField(sessionMode),
sessionId: safeField(sessionId),
threadId: safeField(threadId),
sessionStatus: safeField(sessionStatus),
runnerKind: safeField(runnerKind),
protocol: runtimePath.protocol,
implementationType: runtimePath.implementationType,
providerTraceCommand: runtimePath.command,
providerTraceTerminalStatus: runtimePath.terminalStatus,
providerTraceFailureKind: runtimePath.failureKind,
toolCalls,
skills,
runnerTrace
}
};
}
function classifyPayload(availability: Record<string, unknown> | null, message: Record<string, unknown> | null, blockers: string[], messageStatus: string | null): Pick<CodeAgentStatusSummary, "kind" | "tone" | "label" | "icon"> {
const providerBackend = pickString(availability?.provider as string | undefined, message?.provider as string | undefined);
const sessionRunnerField = availability ? (availability as Record<string, unknown>).sessionRunner : null;
const sessionRunnerKind = pickString(
availability?.sessionRunnerKind as string | undefined,
typeof sessionRunnerField === "string" ? sessionRunnerField : asString(sessionRunnerField)
);
const sessionMode = pickString(availability?.sessionMode as string | undefined, message?.sessionMode as string | undefined);
if (blockers.length > 0) {
return { kind: "blocked", tone: "blocked", label: "Code Agent 阻塞", icon: "×" };
}
if (providerBackend && (sessionRunnerKind === CODEX_APP_SERVER_RUNNER || sessionMode === CODEX_APP_SERVER_SESSION_MODE)) {
return { kind: "long-lived-session", tone: "ok", label: "长会话可用", icon: "●" };
}
if (providerBackend && /codex-stdio|codex-app-server/iu.test(providerBackend)) {
return { kind: "stateless-one-shot", tone: "warn", label: "单次 Code Agent", icon: "○" };
}
if (providerBackend) {
return { kind: "read-only-session-tools", tone: "warn", label: "Provider 受限", icon: "!" };
}
if (messageStatus === "running" || messageStatus === "pending") {
return { kind: "degraded", tone: "source", label: "等待后端", icon: "…" };
}
return { kind: "unverified", tone: "source", label: "未观测", icon: "○" };
}
function readinessBlockers(availability: Record<string, unknown> | null, message: Record<string, unknown> | null): string[] {
const blockers: string[] = [];
const seen = new Set<string>();
function add(value: unknown, prefix = ""): void {
if (typeof value === "string" && value.trim() && value !== "ready" && value !== "ok") {
const key = `${prefix}${value}`;
if (!seen.has(key)) {
seen.add(key);
blockers.push(key);
}
}
}
add((availability?.currentBlocker as string | undefined) ?? (availability?.blocker as string | undefined));
for (const blocker of asArray(availability?.currentBlockers)) add(blocker);
for (const blocker of asArray(availability?.blockerCodes)) add(blocker);
for (const blocker of asArray(availability?.blockers)) {
if (blocker && typeof blocker === "object") {
const code = (blocker as Record<string, unknown>).code;
const message = (blocker as Record<string, unknown>).message;
if (typeof code === "string") add(code);
if (typeof message === "string") add(message);
}
}
if (message) {
const error = asRecord(message.error);
add(error?.code as string | undefined);
add(error?.message as string | undefined);
}
return blockers;
}
function sessionLifecycleSummary(messageStatus: string | null, blockers: string[]): { status: string; hint: string; label: string } {
if (blockers.length > 0) return { status: "阻塞", hint: "等待补齐 provider/session 阻断条件", label: "会话受阻" };
if (messageStatus === "running" || messageStatus === "pending") return { status: "运行", hint: "正在等待后端响应", label: "会话运行中" };
if (messageStatus === "completed") return { status: "完成", hint: "可继续 steer 或新建 session", label: "会话已就绪" };
if (messageStatus === "failed" || messageStatus === "blocked" || messageStatus === "timeout") return { status: "失败", hint: "可新建 session 后重试", label: "会话已失败" };
if (messageStatus === "canceled") return { status: "已取消", hint: "可重新发起", label: "会话已取消" };
return { status: "未观测", hint: "尚未发起 Code Agent 请求", label: "会话未观测" };
}
function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : [];
}
function currentDeploymentRevision(live: LiveSurface | null): string {
const liveRevision = live?.healthLive.data?.revision ?? live?.health.data?.revision ?? null;
return liveRevision ? String(liveRevision).slice(0, 12) : "未观测";
}
export function codeAgentRuntimePathFromMessage(message: Record<string, unknown> | { role?: string; [key: string]: unknown }): CodeAgentRuntimePath {
const record = asRecord(message) ?? {};
const provider = pickString(record.provider as string | undefined, record && asString((record as Record<string, unknown>).provider));
const runnerKind = pickString(record.runnerKind as string | undefined, record.runner && (record.runner as Record<string, unknown>).kind as string | undefined, record.agentRun && (record.agentRun as AgentRunProvenance).adapter as string | undefined);
const protocol = pickString(record.protocol as string | undefined, record.protocol as string | undefined);
const implementationType = pickString(record.implementationType as string | undefined, record.implementationType as string | undefined);
const providerTrace = asRecord(record.providerTrace) ?? {};
const command = pickString(providerTrace.command as string | undefined, providerTrace.commandId as string | undefined, record.agentRun && (record.agentRun as AgentRunProvenance).commandId as string | undefined);
const terminalStatus = pickString(providerTrace.terminalStatus as string | undefined, providerTrace.terminal as string | undefined, record.agentRun && (record.agentRun as AgentRunProvenance).terminalStatus as string | undefined);
const failureKind = pickString(providerTrace.failureKind as string | undefined, providerTrace.failure as string | undefined);
const isCodexAppServer = runnerKind === CODEX_APP_SERVER_RUNNER || provider === "codex-stdio" || /codex-app-server/iu.test(protocol ?? "") || /codex-app-server/iu.test(implementationType ?? "");
const label = isCodexAppServer ? "repo-owned codex app-server stdio" : (provider ?? "运行路径证据不足");
const tone: CodeAgentRuntimePath["tone"] = isCodexAppServer ? "ok" : provider ? "warn" : "blocked";
const summary = isCodexAppServer
? "provider / runner / protocol / implementationType 与 codex app-server stdio 长会话一致;trace 链路可见。"
: provider
? `provider=${provider} 但 runner / protocol 字段未对齐;runtime 路径证据不足。`
: "providerTrace 未观测;不会推断完整 Code Agent。";
const rows: { label: string; value: string }[] = [
{ label: "provider", value: safeField(provider) },
{ label: "runnerKind", value: safeField(runnerKind) },
{ label: "protocol", value: safeField(protocol ?? (isCodexAppServer ? CODEX_APP_SERVER_PROTOCOL : null)) },
{ label: "implementationType", value: safeField(implementationType ?? (isCodexAppServer ? CODEX_APP_SERVER_IMPLEMENTATION : null)) },
{ label: "providerTrace.command", value: safeField(command) },
{ label: "providerTrace.terminalStatus", value: safeField(terminalStatus) },
{ label: "providerTrace.failureKind", value: safeField(failureKind) }
];
const missingFields = rows.filter((row) => row.value === "未观测" || row.value === "字段缺失:证据不足").map((row) => row.label);
return {
protocol: rows[2]?.value ?? "未观测",
implementationType: rows[3]?.value ?? "未观测",
command: rows[4]?.value ?? "未观测",
terminalStatus: rows[5]?.value ?? "未观测",
failureKind: rows[6]?.value ?? "未观测",
label,
summary,
tone,
rows,
missingFields
};
}
function compactToolCallsSummary(toolCalls: unknown): string {
if (!toolCalls) return "none";
if (typeof toolCalls === "string") return toolCalls;
if (Array.isArray(toolCalls)) {
if (toolCalls.length === 0) return "none";
return toolCalls.map((tool) => {
if (typeof tool === "string") return tool;
const record = asRecord(tool);
return record?.name ? String(record.name) : jsonPreview(tool, 60);
}).join(", ");
}
const record = asRecord(toolCalls);
if (!record) return "none";
return record.name ? String(record.name) : jsonPreview(record, 120);
}
function compactSkillsSummary(skills: unknown): string {
if (!skills) return "none";
if (typeof skills === "string") return skills;
if (Array.isArray(skills)) {
if (skills.length === 0) return "none";
return skills.map((skill) => asString(skill) ?? jsonPreview(skill, 60)).join(", ");
}
const record = asRecord(skills);
if (!record) return "none";
return record.id ? String(record.id) : jsonPreview(record, 120);
}
function compactRunnerTraceSummary(runnerTrace: unknown): string {
if (!runnerTrace) return "未观测";
const record = asRecord(runnerTrace);
if (!record) return "未观测";
const eventCount = Array.isArray(record.events) ? (record.events as unknown[]).length : Number(record.eventCount ?? 0);
const status = asString(record.status);
const sessionId = shortToken(asString(record.sessionId), 8);
const lastLabel = asString(record.lastEventLabel) ?? asString(record.lastEvent) ?? asString(record.waitingFor) ?? "无";
const parts: string[] = [];
if (status) parts.push(`status=${status}`);
parts.push(`events=${eventCount || 0}`);
if (sessionId !== "unknown") parts.push(`session=${sessionId}`);
parts.push(`last=${lastLabel}`);
return parts.join(" / ");
}
export function codeAgentSummaryRows(summary: CodeAgentStatusSummary): { key: string; value: string; tone: CodeAgentStatusSummary["tone"] | "source" }[] {
return [
{ key: "codeAgent.status", value: summary.codeAgentStatus, tone: summary.tone },
{ key: "provider/mode/backend", value: summary.providerModeBackend, tone: "source" },
{ key: "capabilityLevel", value: summary.capabilityLevel, tone: summary.tone },
{ key: "session", value: summary.sessionSummary, tone: sessionSummaryTone(summary) },
{ key: "sessionId/status", value: summary.sessionIdStatus, tone: sessionSummaryTone(summary) },
{ key: "会话提示", value: summary.sessionLifecycleHint, tone: sessionSummaryTone(summary) },
{ key: "workspace", value: summary.workspace, tone: "source" },
{ key: "sandbox", value: summary.sandbox, tone: "source" },
{ key: "runnerKind", value: summary.runnerKind, tone: summary.kind === "long-lived-session" ? "ok" : "source" },
{ key: "protocol", value: summary.protocol, tone: summary.runtimePathTone },
{ key: "implementationType", value: summary.implementationType, tone: summary.runtimePathTone },
{ key: "providerTrace.command", value: summary.providerTraceCommand, tone: summary.runtimePathTone },
{ key: "providerTrace.terminalStatus", value: summary.providerTraceTerminalStatus, tone: summary.runtimePathTone },
{ key: "providerTrace.failureKind", value: summary.providerTraceFailureKind, tone: summary.runtimePathTone },
{ key: "运行路径语义", value: summary.runtimePathLabel, tone: summary.runtimePathTone },
{ key: "toolCalls", value: summary.toolCalls, tone: summary.toolCalls === "none" ? "source" : summary.tone },
{ key: "skills", value: summary.skills, tone: summary.skills === "none" ? "source" : summary.tone },
{ key: "conversation facts", value: summary.fields.runnerTrace ?? "未观测", tone: "source" },
{ key: "runnerTrace", value: summary.runnerTrace, tone: "source" },
{ key: "readiness blockers", value: summary.blockersLabel, tone: summary.readinessBlockers.length > 0 ? "blocked" : "source" },
{ key: "last traceId", value: summary.lastTraceId, tone: "source" },
{ key: "当前部署 revision", value: summary.deploymentRevision, tone: "source" }
];
}
function sessionSummaryTone(summary: CodeAgentStatusSummary): CodeAgentStatusSummary["tone"] {
if (summary.kind === "long-lived-session") return "ok";
if (summary.kind === "blocked") return "blocked";
if (["read-only-session-tools", "stateless-one-shot", "fallback-text-chat-only", "degraded"].includes(summary.kind)) return "warn";
return "source";
}
export function summarizeLastEvent(trace: RunnerTrace | null | undefined): { label: string; ts: string | null; tone: CodeAgentStatusSummary["tone"] } {
if (!trace) return { label: "等待后端事件", ts: null, tone: "source" };
const events = Array.isArray(trace.events) ? trace.events as TraceEvent[] : [];
const last = events[events.length - 1] ?? null;
const lastLabel = String(last?.label ?? last?.type ?? trace.lastEventLabel ?? trace.lastEvent ?? trace.waitingFor ?? "等待后端事件");
const ts: string | null = typeof last?.ts === "string" ? last.ts : (typeof trace.updatedAt === "string" ? trace.updatedAt : null);
const status = String(last?.status ?? "");
const tone: CodeAgentStatusSummary["tone"] = status === "completed" ? "ok" : status === "running" || status === "pending" ? "warn" : status === "failed" ? "blocked" : "source";
return { label: lastLabel, ts, tone };
}
@@ -84,6 +84,26 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.code-agent-summary { border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; background: #fbfcf8; }
.code-agent-summary summary { display: flex; gap: 10px; align-items: center; cursor: pointer; min-width: 0; }
.code-agent-summary-detail pre, .message-trace pre, .skill-preview, .help-content, #device-detail-body { white-space: pre-wrap; overflow-wrap: anywhere; }
.code-agent-summary-rows { display: grid; gap: 4px 14px; grid-template-columns: minmax(180px, max-content) 1fr; align-items: baseline; }
.code-agent-summary-row { display: contents; }
.code-agent-summary-key { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.code-agent-summary-value { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
.code-agent-summary-raw { margin-top: 8px; font-size: 12px; color: var(--muted); }
.code-agent-summary-raw pre { margin: 4px 0 0; max-height: 220px; overflow: auto; background: #f3f5f1; padding: 8px; border-radius: 6px; }
.message-trace-toolbar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #f7f8f4; }
.message-trace-toolbar-spacer { flex: 1; }
.message-trace-toolbar button { padding: 4px 10px; min-height: 28px; }
.message-trace-count, .message-trace-storage-key { color: var(--muted); font-size: 12px; }
.message-trace-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; padding: 8px 10px; }
.message-trace-row { border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; background: #fff; }
.message-trace-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
.message-trace-tone { color: var(--accent); }
.message-trace-label { font-weight: 700; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
.message-trace-time { color: var(--muted); font-size: 12px; }
.message-trace-body { margin: 6px 0 0; max-height: 220px; overflow: auto; background: #f3f5f1; padding: 6px 8px; border-radius: 6px; font-size: 12px; }
.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; }
.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; }
+51 -7
View File
@@ -34,13 +34,57 @@ export function formatTimestamp(value: unknown): string {
if (!text) return "未知";
const date = new Date(text);
if (Number.isNaN(date.getTime())) return "未知";
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(date);
return formatBeijingTime(date);
}
const BEIJING_FORMATTER = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
});
const BEIJING_SHORT_FORMATTER = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
});
function toBeijingParts(value: Date | string | number, withSeconds: boolean): Intl.DateTimeFormatPart[] | null {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return (withSeconds ? BEIJING_FORMATTER : BEIJING_SHORT_FORMATTER).formatToParts(date);
}
export function formatBeijingTime(value: unknown, options: { withSeconds?: boolean; includeYear?: boolean } = {}): string {
const parts = toBeijingParts(value as Date | string | number, Boolean(options.withSeconds));
if (!parts) return "未知";
const lookup = new Map(parts.map((part) => [part.type, part.value]));
const year = lookup.get("year") ?? "";
const month = lookup.get("month") ?? "00";
const day = lookup.get("day") ?? "00";
const hour = lookup.get("hour") ?? "00";
const minute = lookup.get("minute") ?? "00";
const second = lookup.get("second") ?? "00";
const datePart = options.includeYear === false ? `${month}-${day}` : `${year}-${month}-${day}`;
return options.withSeconds ? `${datePart} ${hour}:${minute}:${second}` : `${datePart} ${hour}:${minute}`;
}
export function formatDuration(ms: unknown): string {
const value = Number(ms);
if (!Number.isFinite(value) || value < 0) return "未知";
if (value < 1000) return `${Math.round(value)}ms`;
if (value < 60_000) return `${(value / 1000).toFixed(value < 10_000 ? 2 : 1)}s`;
const minutes = Math.floor(value / 60_000);
const seconds = Math.floor((value % 60_000) / 1000);
return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
}
export function relativeUpdatedLabel(timestampMs: number): string {