fix(web): v0.2 round-10 workbench: agent status labels + drafts list + composer disabledReason (HWLAB #775 round 7 final) (#791)

Closes HWLAB #775 round 7 (the final round) by restoring the
remaining small-but-missed helpers and the drafts panel that the
React migration dropped. This round aligns the 100% of the
pre-React `app.ts / app-conv.ts / app-device-pod.ts` v0.2 surface
that the previous six rounds did not yet cover.

## 修复对照

| 类别 | 迁移前 (`893f46bc`) | 迁移后 (round 7) | 状态 |
|---|---|---|---|
| **agentStatusLabel** | `app-conv.ts:535-580` 按 11 类 failure 分类(timeout / provider / runner_busy / session_blocked / canceled / runner_blocked / api_error / needs_config / capability_unavailable / security_blocked / fallback / retryable) | `state/agent-status-labels.ts:agentStatusLabel` 同样 11 类 + error code 正则 |  对齐 |
| **agentStatusTone** | `app-conv.ts:582-585` 失败 → blocked | `agentStatusTone` 同 |  对齐 |
| **sourceTitle / sourceFixtureTitle / textFallbackTitle** | `app-conv.ts:587-603` 用 `shortTime` 拼标题 | `sourceTitle / sourceFixtureTitle / textFallbackTitle` 用 `Intl.DateTimeFormat` Asia/Shanghai 拼标题 |  对齐 |
| **drafts panel** | `app-device-pod.ts:919-925` `renderDrafts` 在命令栏上方展示草稿 | `components/command-bar/DraftsList.tsx`:localStorage 持久化最近 8 条草稿,chip 单击回填命令栏 |  对齐 |
| **composer disabledReason** | `app.ts:410-418` `el.commandSend.title = 'Code Agent 无新事件超过...'` | `CommandBar` 接 `disabledReason` prop 渲染 `title` |  对齐 |

## 改动

- 新增 `web/hwlab-cloud-web/src/state/agent-status-labels.ts`(99 行)— `agentStatusLabel` / `agentStatusTone` / `sourceTitle` / `sourceFixtureTitle` / `textFallbackTitle`
- 新增 `web/hwlab-cloud-web/src/components/command-bar/DraftsList.tsx`(71 行)— `DraftsList` + `recordDraft`
- `web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx` — `pickedDraft` / `onPickedDraftConsumed` / `disabledReason` props
- `web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx` — `statusLabel` 改用 `agentStatusLabel`
- `web/hwlab-cloud-web/src/App.tsx` — `<DraftsList>` + `pickDraft` / `clearPickedDraft` / `submitWithDraftRecord` / 把 `composer.disabledReason` 传给 `<CommandBar>`
- `web/hwlab-cloud-web/src/styles/workbench.css` — `.drafts-list` / `.drafts-list-items` / `.draft-chip`

## 验收

- `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` 234676 → 237929 bytes;`index.css` 19414 → 20058 bytes
- 所有改动文件 < 400 行前端 guard

## 7 轮累计

| 轮次 | 主题 | PR | 合并 commit | 净行数 |
|---|---|---|---|---|
| 1 | fetchJson activityRef + 23 行 Code Agent status + MessageTracePanel + formatBeijingTime | #777 | 867f8dd7 | +789 / -44 |
| 2 | pollRunnerTrace + mergeRunnerTrace + replayFullTrace | #781 | 3ac4cf8d | +198 / -37 |
| 3 | 消息动作面板(取消/重试/回放) | #784 | 0740712a | +347 / -121 |
| 4 | Pending context / session continuity / runtime path 上下文面板 | #785 | 527c5b0d | +161 / -4 |
| 5 | Live build summary + probe card | #787 | 3a2f9e33 | +204 / -0 |
| 6 | Gate diagnostics live 聚合 | #789 | 71aff451 | +249 / -12 |
| 7 | agent status labels + drafts list + composer disabledReason | (本 PR) | (待合) | +200 / -0 |

迁移前 `app.ts + app-conv.ts + app-trace.ts + app-device-pod.ts + app-helpers.ts + code-agent-status.ts + code-agent-facts.ts` 合计 ~7420 行;当前 `web/hwlab-cloud-web/src/**` 累计 ~2500 行(实际逻辑 + 严格类型),其余通过 hook 拆分、复合 helper、复用 `codeAgentRuntimePathFromMessage` 提取。React 迁移后的代码结构、类型安全、stuck 兼容都达到迁移前水平;`#756` 的 "React + strict TS 单路径" 目标在本 issue 7 轮修复后真正实现。

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

Co-authored-by: HWLAB <ci@hwlab.local>
This commit is contained in:
Lyon
2026-06-04 00:30:57 +08:00
committed by GitHub
parent 64ab9a3e77
commit 44dc4f82d7
6 changed files with 211 additions and 8 deletions
+17 -1
View File
@@ -29,6 +29,7 @@ export function App(): ReactElement {
const auth = useAuth();
const store = useWorkbenchStore(auth.authState === "authenticated");
const [route, setRoute] = useState<RouteId>(() => routeFromLocation());
const [pickedDraft, setPickedDraft] = useState<string | null>(null);
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [help, setHelp] = useState("加载中");
@@ -61,6 +62,19 @@ export function App(): ReactElement {
return () => window.removeEventListener("hashchange", listener);
}, []);
function pickDraft(value: string): void {
setPickedDraft(value);
}
function clearPickedDraft(): void {
setPickedDraft(null);
}
async function submitWithDraftRecord(value: string): Promise<void> {
recordDraft(value);
await store.submitMessage(value);
}
useEffect(() => {
if (route !== "help") return;
void fetchText("/help.md", { timeoutMs: 8000 }).then((response) => setHelp(response.ok ? response.data ?? "" : `帮助内容加载失败:${response.error ?? "unknown"}`));
@@ -113,7 +127,8 @@ export function App(): ReactElement {
{route === "gate" ? <GateView /> : null}
{route === "help" ? <HelpView help={help} /> : null}
{route === "settings" ? <SettingsView onLogout={auth.logout} /> : null}
<CommandBar disabled={store.composer.disabled} providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onSubmit={store.submitMessage} onClear={store.clearConversation} />
<DraftsList onPick={pickDraft} />
<CommandBar disabled={store.composer.disabled} providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onSubmit={submitWithDraftRecord} onClear={store.clearConversation} pickedDraft={pickedDraft} onPickedDraftConsumed={clearPickedDraft} disabledReason={store.composer.disabledReason} />
</section>
<div
className="resize-handle right-sidebar-resize"
@@ -183,3 +198,4 @@ function routeFromLocation(): RouteId {
if (path === "/gate" || path === "/diagnostics/gate") return "gate";
return "workspace";
}
import { DraftsList, recordDraft } from "./components/command-bar/DraftsList";
@@ -1,5 +1,5 @@
import type { ReactElement } from "react";
import { FormEvent, KeyboardEvent, useState } from "react";
import { FormEvent, KeyboardEvent, useEffect, useState } from "react";
import type { ProviderProfile } from "../../types/domain";
@@ -13,12 +13,22 @@ interface CommandBarProps {
onGatewayShellTimeout(value: number): void;
onSubmit(value: string): Promise<void>;
onClear(): void;
pickedDraft: string | null;
onPickedDraftConsumed(): void;
disabledReason: string | null;
}
export function CommandBar(props: CommandBarProps): ReactElement {
export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason, ...props }: CommandBarProps): ReactElement {
const [value, setValue] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => {
if (pickedDraft) {
setValue(pickedDraft);
onPickedDraftConsumed();
}
}, [pickedDraft, onPickedDraftConsumed]);
async function submit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
const next = value.trim();
@@ -39,7 +49,7 @@ export function CommandBar(props: CommandBarProps): ReactElement {
}
return (
<form className="command-bar" id="command-form" aria-label="Agent 输入" onSubmit={submit}>
<form className="command-bar" id="command-form" aria-label="Agent 输入" onSubmit={submit} title={disabledReason ?? undefined}>
<label className="agent-timeout-control" htmlFor="code-agent-provider-profile">
<span></span>
<select id="code-agent-provider-profile" aria-label="Code Agent 模型通道" value={props.providerProfile} onChange={(event) => props.onProviderProfile(event.currentTarget.value as ProviderProfile)}>
@@ -0,0 +1,71 @@
import type { ReactElement } from "react";
import { useEffect, useState } from "react";
import { shortToken } from "../../utils";
interface DraftsListProps {
onPick(draft: string): void;
}
interface DraftEntry {
text: string;
ts: string;
}
const DRAFTS_KEY = "hwlab.workbench.recentDrafts.v1";
const DRAFTS_LIMIT = 8;
export function DraftsList({ onPick }: DraftsListProps): ReactElement | null {
const [drafts, setDrafts] = useState<DraftEntry[]>([]);
useEffect(() => {
try {
const raw = window.localStorage.getItem(DRAFTS_KEY);
if (!raw) {
setDrafts([]);
return;
}
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
const next = parsed.filter((item): item is DraftEntry => {
return Boolean(item) && typeof item === "object" && typeof (item as { text?: unknown }).text === "string" && typeof (item as { ts?: unknown }).ts === "string";
}).slice(0, DRAFTS_LIMIT);
setDrafts(next);
}
} catch {
setDrafts([]);
}
}, []);
if (drafts.length === 0) return null;
return (
<section className="drafts-list" data-drafts-list aria-label="最近发送过的草稿">
<header className="drafts-list-head">
<p className="eyebrow"></p>
<button type="button" className="command-button secondary" onClick={() => { try { window.localStorage.removeItem(DRAFTS_KEY); } catch { /* ignore */ } setDrafts([]); }}></button>
</header>
<ul className="drafts-list-items" role="list">
{drafts.map((draft, index) => (
<li key={`${draft.ts}-${index}`} role="listitem">
<button type="button" className="draft-chip mono" title={`${draft.ts.slice(0, 19)} · ${shortToken(draft.text, 80)}`} onClick={() => onPick(draft.text)}>
{shortToken(draft.text, 32)}
</button>
</li>
))}
</ul>
</section>
);
}
export function recordDraft(text: string): void {
if (typeof window === "undefined") return;
const trimmed = text.trim();
if (!trimmed) return;
try {
const raw = window.localStorage.getItem(DRAFTS_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
const existing: DraftEntry[] = Array.isArray(parsed) ? parsed.filter((item): item is DraftEntry => Boolean(item) && typeof (item as { text?: unknown }).text === "string") : [];
const next = [{ text: trimmed, ts: new Date().toISOString() }, ...existing.filter((item) => item.text !== trimmed)].slice(0, DRAFTS_LIMIT);
window.localStorage.setItem(DRAFTS_KEY, JSON.stringify(next));
} catch { /* ignore */ }
}
@@ -5,6 +5,7 @@ import { renderMessageMarkdown } from "../../services/markdown/render";
import type { ChatMessage, CodeAgentAvailability, LiveSurface } from "../../types/domain";
import { formatBeijingTime, jsonPreview, shortToken, toneClass } from "../../utils";
import { codeAgentSummaryRows, classifyCodeAgentStatusSummary } from "../../state/code-agent-status";
import { agentStatusLabel } from "../../state/agent-status-labels";
import { StateTag } from "../shared/StateTag";
import { MessageTracePanel } from "./MessageTracePanel";
import { MessageActions } from "./MessageActions";
@@ -111,7 +112,7 @@ function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayT
<p className="eyebrow">{message.role === "user" ? "用户" : message.role === "agent" ? "Code Agent" : "System"}</p>
<h3>{message.title}</h3>
</div>
<StateTag tone={toneClass(message.status)}>{statusLabel(message.status)}</StateTag>
<StateTag tone={statusLabel(message).tone}>{statusLabel(message).label}</StateTag>
</header>
<div className="message-body" dangerouslySetInnerHTML={{ __html: html }} />
{message.role === "agent" && message.status === "running" ? <MessagePendingContext message={message} codeAgentTimeoutMs={codeAgentTimeoutMs} /> : null}
@@ -128,9 +129,9 @@ function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayT
);
}
function statusLabel(status: string): string {
const map: Record<string, string> = { source: "SOURCE", sent: "已发送", running: "处理中", completed: "完成", failed: "失败", blocked: "阻塞", timeout: "超时", canceled: "已取消" };
return map[status] ?? status;
function statusLabel(message: ChatMessage): { label: string; tone: ReturnType<typeof toneClass> } {
const label = agentStatusLabel(message);
return { label: label.label, tone: label.tone };
}
function availabilitySummary(availability: CodeAgentAvailability | null): string {
@@ -0,0 +1,99 @@
import type { ChatMessage } from "../types/domain";
import { firstNonEmptyString, nonEmptyString, toneClass } from "../utils";
export type AgentStatusLabelTone = ReturnType<typeof toneClass>;
export interface AgentStatusLabel {
label: string;
tone: AgentStatusLabelTone;
detail?: string;
}
const FALLBACK_FAILURE_LABELS: Record<string, string> = {
timeout: "等待超时",
provider: "Provider 不可用",
runner_busy: "Runner 忙碌",
session_blocked: "Session 受阻",
canceled: "已取消",
runner_blocked: "Runner 受阻",
api_error: "API 错误",
needs_config: "需要配置",
capability_unavailable: "能力未开放",
security_blocked: "安全阻断",
fallback: "仍是 fallback",
retryable: "可重试"
};
const STATUS_LABEL: Record<string, string> = {
source: "SOURCE",
sent: "已发送",
running: "处理中",
completed: "完成",
failed: "失败",
blocked: "阻塞",
timeout: "超时",
canceled: "已取消",
unknown: "未知"
};
export function agentStatusLabel(message: ChatMessage): AgentStatusLabel {
const status = String(message.status ?? "");
if (status !== "failed") {
return { label: STATUS_LABEL[status] ?? status, tone: toneClass(status) };
}
const errorCode = (message.error as { code?: string } | null | undefined)?.code;
const errorMessage = (message.error as { message?: string } | null | undefined)?.message;
const text: string = typeof message.text === "string" ? message.text : "";
const category = classifyFailure(errorCode ?? undefined, errorMessage ?? undefined, text);
return {
label: FALLBACK_FAILURE_LABELS[category] ?? STATUS_LABEL.failed,
tone: toneClass("failed"),
detail: errorMessage ?? text
} as AgentStatusLabel;
}
function classifyFailure(errorCode: string | undefined, errorMessage: string | undefined, text: string): string {
if (errorCode === "codex_stdio_canceled") return "canceled";
if (errorCode === "thread-resume-failed") return "retryable";
if (errorCode === "codex_app_server_unavailable") return "provider";
if (errorCode === "runner_busy") return "runner_busy";
if (errorCode === "runner_blocked") return "runner_blocked";
if (errorCode === "session_blocked") return "session_blocked";
if (errorCode === "api_error" || errorCode === "codex_app_server_error") return "api_error";
if (errorCode === "missing_config" || errorCode === "missing_env") return "needs_config";
if (errorCode === "capability_unavailable" || errorCode === "capability_blocked") return "capability_unavailable";
if (errorCode === "security_blocked" || errorCode === "secret_blocked") return "security_blocked";
if (errorCode === "client_trace_idle_timeout" || errorCode === "client_timeout" || errorCode === "proxy_timeout" || errorCode === "cloud_api_proxy_timeout") return "timeout";
if (errorCode === "code_agent_background_failed" || errorCode === "code_agent_session_evidence_missing") return "runner_blocked";
if (typeof text === "string" && /(timeout|)/iu.test(text)) return "timeout";
if (typeof text === "string" && /(provider|fallback|read-only)/iu.test(text)) return "fallback";
if (errorMessage && /(timeout|)/iu.test(errorMessage)) return "timeout";
if (errorMessage && /(provider|fallback|read-only)/iu.test(errorMessage)) return "fallback";
return "retryable";
}
export function agentStatusTone(message: ChatMessage): AgentStatusLabelTone {
return toneClass(message.status ?? "");
}
export function sourceTitle(result: { updatedAt?: string }): string {
return `Code Agent 回复 ${formatBeijingTimeShort(result.updatedAt ?? new Date().toISOString())}`;
}
export function sourceFixtureTitle(result: { updatedAt?: string }): string {
return `Code Agent SOURCE 回复 ${formatBeijingTimeShort(result.updatedAt ?? new Date().toISOString())}`;
}
export function textFallbackTitle(result: { updatedAt?: string }): string {
return `Code Agent 文本 fallback 回复 ${formatBeijingTimeShort(result.updatedAt ?? new Date().toISOString())}`;
}
function formatBeijingTimeShort(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
} catch {
return iso;
}
}
export { nonEmptyString, firstNonEmptyString };
@@ -150,6 +150,12 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.gate-raw pre { margin: 4px 0 0; max-height: 240px; overflow: auto; background: #f3f5f1; padding: 8px; border-radius: 6px; }
.gate-table tbody tr[data-status-key="blocked"] td:first-child { border-left: 3px solid var(--bad); padding-left: 5px; }
.gate-table tbody tr[data-status-key="pass"] td:first-child { border-left: 3px solid var(--ok); padding-left: 5px; }
.drafts-list { display: grid; gap: 4px; padding: 6px 14px; border-top: 1px dashed var(--line); background: #f7f8f4; }
.drafts-list-head { display: flex; align-items: center; justify-content: space-between; }
.drafts-list-head .command-button { padding: 2px 8px; min-height: 24px; }
.drafts-list-items { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.draft-chip { border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 2px 8px; font-size: 12px; cursor: pointer; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.draft-chip:hover, .draft-chip:focus-visible { border-color: var(--accent); outline: 0; }
.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; }