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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user