fix(web): HWLAB #744 WebUI v0.2 改进 (#746)

- 问题1: 修复重复 session - 按 threadId 去重 conversation
- 问题2: session sidebar 默认宽度改为150% (330px),移除上限
- 问题3: 删除重复的命令栏「新建 Session」按钮
- 问题4: session 报错(status=failed/error/canceled/timeout)后允许继续重试
- 问题5: session tabs 上方显示当前模型通道
- 问题6: 所有时间统一使用北京时间(UTC+8),移除「北京时间」字样
- 问题7: 注销按钮移入 activity-rail 设置标签

Closes #744

Co-authored-by: Codex <codex@local>
This commit is contained in:
Lyon
2026-06-03 13:13:23 +08:00
committed by GitHub
parent a72f14166e
commit 2168ad2ad7
8 changed files with 88 additions and 15 deletions
+3 -2
View File
@@ -407,9 +407,10 @@ function threadIdForNextRequest() {
}
function isTerminalSessionStatus(status) {
return ["failed", "interrupted", "timeout", "error", "canceled", "expired"].includes(String(status ?? "").toLowerCase());
return ["blocked", "expired", "stale", "thread-resume-failed"].includes(String(status ?? "").toLowerCase());
}
function applyCodeAgentResultToMessage(messageId, result, options = {}) {
const index = state.chatMessages.findIndex((message) => message.id === messageId);
if (index < 0 || !result || typeof result !== "object") return null;
@@ -1694,7 +1695,7 @@ function formatBeijingTime(value) {
second: "2-digit",
hour12: false
}).formatToParts(date).map((part) => [part.type, part.value]));
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second} 北京时间`;
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
}
function shortToken(value) {
+11 -2
View File
@@ -164,9 +164,10 @@ function cell(text, className) {
function formatTimestamp(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "未知";
return shortTime(date.toISOString());
return beijingTimeString(date.toISOString());
}
function tableCell(text, className, colspan = 1) {
const td = cell(text, className);
if (colspan > 1) td.colSpan = colspan;
@@ -631,9 +632,17 @@ function valueOrUnavailable(liveValue, sourceValue) {
}
function shortTime(value) {
return new Date(value).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z");
return beijingTimeString(value);
}
function beijingTimeString(value) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "未知";
const beijing = new Date(date.getTime() + 8 * 60 * 60 * 1000);
return beijing.toISOString().replace("T", " ").replace(/.\d{3}Z$/, "");
}
function toneClass(tone) {
return String(tone ?? "unknown").replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
}
+42 -3
View File
@@ -100,9 +100,9 @@ const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1";
const LEFT_SIDEBAR_DEFAULT_WIDTH = 92;
const LEFT_SIDEBAR_MIN_WIDTH = 72;
const LEFT_SIDEBAR_MAX_WIDTH = 180;
const SESSION_SIDEBAR_DEFAULT_WIDTH = 220;
const SESSION_SIDEBAR_DEFAULT_WIDTH = 330;
const SESSION_SIDEBAR_MIN_WIDTH = 148;
const SESSION_SIDEBAR_MAX_WIDTH = 320;
const SESSION_SIDEBAR_MAX_WIDTH = 1200;
const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728;
const RIGHT_SIDEBAR_MIN_WIDTH = 560;
const RIGHT_SIDEBAR_MAX_WIDTH = 740;
@@ -139,6 +139,7 @@ const el = {
sessionTabs: byId("session-tabs"),
sessionCreate: byId("session-create"),
sessionDelete: byId("session-delete"),
sessionModelChannel: byId("session-model-channel"),
sessionStatus: byId("session-status"),
conversationList: byId("conversation-list"),
gateSourceStatus: byId("gate-source-status"),
@@ -158,7 +159,6 @@ const el = {
skillPreview: byId("skill-preview"),
commandForm: byId("command-form"),
commandInput: byId("command-input"),
commandNewSession: byId("command-new-session"),
codeAgentProviderProfile: byId("code-agent-provider-profile"),
codeAgentTimeout: byId("code-agent-timeout"),
gatewayShellTimeout: byId("gateway-shell-timeout"),
@@ -427,6 +427,7 @@ function syncCodeAgentTimeoutControl() {
el.codeAgentTimeout.title = `Code Agent 无新事件超过 ${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 后才显示为超时`;
el.gatewayShellTimeout.value = String(GATEWAY_SHELL_TIMEOUT_MS);
el.gatewayShellTimeout.title = `Code Agent 调用 PC gateway wrapper 时默认使用 --timeout-ms ${GATEWAY_SHELL_TIMEOUT_MS}`;
renderSessionModelChannel();
}
function restoreCodeAgentSessionState() {
@@ -690,6 +691,11 @@ async function copyTextToClipboard(value) {
return ok;
}
function sessionSidebarConversations() {
const deduplicated = deduplicateConversationsByThread(sessionSidebarConversationsRaw());
return deduplicated;
}
function sessionSidebarConversationsRaw() {
const items = Array.isArray(state.sessionList.items) ? [...state.sessionList.items] : [];
if (state.conversationId && !items.some((item) => item?.conversationId === state.conversationId)) {
items.unshift(currentStateConversationSnapshot());
@@ -697,6 +703,29 @@ function sessionSidebarConversations() {
return items.filter(Boolean);
}
function deduplicateConversationsByThread(conversations) {
if (!Array.isArray(conversations) || conversations.length <= 1) return conversations;
const seen = new Map();
for (const conversation of conversations) {
const threadId = String(conversation?.threadId ?? "").trim() || null;
if (!threadId) {
seen.set(JSON.stringify(conversation), conversation);
continue;
}
const existing = seen.get(threadId);
if (!existing) {
seen.set(threadId, conversation);
continue;
}
const existingTime = Date.parse(existing?.updatedAt ?? "");
const currentTime = Date.parse(conversation?.updatedAt ?? "");
if (Number.isFinite(currentTime) && (!Number.isFinite(existingTime) || currentTime > existingTime)) {
seen.set(threadId, conversation);
}
}
return [...seen.values()];
}
function currentStateConversationSnapshot() {
return {
conversationId: state.conversationId,
@@ -718,6 +747,16 @@ function sessionSidebarStatusText(count) {
return count > 0 ? `${count} 个 session` : "无 session";
}
function renderSessionModelChannel() {
if (!el.sessionModelChannel) return;
const label = CODE_AGENT_PROVIDER_PROFILE === "codex-api"
? "模型通道:Codex API"
: CODE_AGENT_PROVIDER_PROFILE === "minimax-m3"
? "模型通道:MiniMax-M3"
: "模型通道:DeepSeek";
el.sessionModelChannel.textContent = label;
}
function sessionTabButton(tab) {
const button = document.createElement("button");
button.type = "button";
+3 -3
View File
@@ -87,7 +87,7 @@ test("composer requires a new manual session after failed session", () => {
});
expect(state.submitMode).toBe("turn");
expect(state.disabled).toBe(true);
expect(state.disabledReason).toBe("session_not_usable");
expect(state.sessionUsable).toBe(false);
expect(state.disabled).toBe(false);
expect(state.disabledReason).toBeNull();
expect(state.sessionUsable).toBe(true);
});
+1 -1
View File
@@ -1,6 +1,6 @@
const ACTIVE_STATUSES = Object.freeze(["running", "pending", "accepted", "processing"]);
const COMPLETED_STATUSES = Object.freeze(["completed", "source", "idle", "ready", "active"]);
const UNUSABLE_SESSION_STATUSES = Object.freeze(["failed", "timeout", "canceled", "cancelled", "error", "blocked", "expired", "interrupted", "stale", "thread-resume-failed"]);
const UNUSABLE_SESSION_STATUSES = Object.freeze(["blocked", "expired", "stale", "thread-resume-failed"]);
const TERMINAL_STATUSES = Object.freeze([...COMPLETED_STATUSES, ...UNUSABLE_SESSION_STATUSES]);
export function computeCodeAgentComposerState(input = {}) {
+16 -2
View File
@@ -38,6 +38,7 @@
<button class="rail-button" type="button" data-route="gate" title="内部复核" aria-label="内部复核">内部复核</button>
<button class="rail-button" type="button" data-route="help" title="使用说明" aria-label="使用说明">使用说明</button>
<span class="rail-spacer" aria-hidden="true"></span>
<button class="rail-button" type="button" data-route="settings" title="设置" aria-label="SZ">设置</button>
<button
class="rail-button sidebar-toggle"
id="left-sidebar-toggle"
@@ -56,6 +57,7 @@
</div>
<button class="session-icon-button" id="session-create" type="button" aria-label="创建 session" title="创建 session">+</button>
</header>
<div class="session-model-channel" id="session-model-channel" aria-live="polite">模型通道</div>
<div class="session-tabs" id="session-tabs" role="tablist" aria-label="Code Agent sessions"></div>
<div class="session-sidebar-foot">
<span class="session-status" id="session-status">等待 workspace</span>
@@ -206,7 +208,21 @@
</tbody>
</table>
</div>
</section
<section class="view settings-view" id="settings" data-view="settings" aria-labelledby="settings-title" hidden>
<div class="workspace-panel settings-panel">
<div class="panel-title-row">
<div>
<p class="eyebrow">账号</p>
<h2 id="settings-title">设置</h2>
</div>
</div>
<div class="settings-content">
<button class="logout-button" id="logout-button" type="button">注销登录</button>
</div>
</div>
</section>
>
<form class="command-bar" id="command-form" aria-label="Agent 输入">
<label class="agent-timeout-control" for="code-agent-provider-profile">
@@ -248,7 +264,6 @@
placeholder="输入要发送给 Code Agent 的消息;不会直接触发硬件变更。"
></textarea>
</div>
<button class="command-button secondary" id="command-new-session" type="button">新建 Session</button>
<button class="command-button" id="command-send" type="submit">发送</button>
<button class="command-button secondary" id="command-clear" type="button">清空</button>
</form>
@@ -265,7 +280,6 @@
aria-label="折叠右侧 Device Pod 看板"
title="折叠右侧 Device Pod 看板"
>收起看板</button>
<button class="logout-button" id="logout-button" type="button">退出</button>
</div>
<header class="sidebar-workbench-header">
<p class="eyebrow">用户工作台</p>
-2
View File
@@ -68,10 +68,8 @@ assert.ok(rightSidebar, "Device Pod right sidebar must exist");
assert.match(html, /data-route="skills"/u, "Cloud Web must expose the skills activity route");
assert.match(html, /id="session-tabs"[^>]*role="tablist"/u, "Cloud Web workspace must expose session tabs");
assert.match(html, /class="session-sidebar"[^>]*id="session-sidebar"/u, "Cloud Web must place sessions in a second-level sidebar");
assert.match(html, /id="command-new-session"/u, "Cloud Web command bar must expose explicit Code Agent session creation");
assertIncludes(app, "/v1/agent/sessions", "Cloud Web must create/select Code Agent sessions explicitly");
assertIncludes(app, "session_required", "Cloud Web must block normal turns until a session is explicitly created");
assertIncludes(app, "session_not_usable", "Cloud Web must block failed/stale Code Agent sessions instead of auto rolling");
assert.match(html, /id="skills"[^>]*data-view="skills"/u, "Cloud Web must expose a skills view");
assert.match(html, /id="skill-upload-input"[^>]*webkitdirectory/u, "Skills view must support directory upload");
assertIncludes(app, "/v1/skills", "skills frontend must call /v1/skills");
+12
View File
@@ -307,6 +307,16 @@ body > [data-app-shell][hidden] {
outline: 0;
}
.session-model-channel {
padding: 4px 8px;
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
background: rgba(19, 22, 20, 0.98);
border-bottom: 1px solid var(--line);
text-align: center;
}
.session-tabs {
min-width: 0;
min-height: 0;
@@ -1369,6 +1379,8 @@ h3 {
overflow-wrap: anywhere;
}
}
.session-tabs {
display: flex;
flex-wrap: wrap;