fix(issue740): HWLAB v0.2 WEBUI session management improvements

1. 在 panel-title-row 增加"复制 session id"按钮,把当前会话
   conversation id 复制到剪贴板,便于在 HWLAB CLI 复现;
2. 侧边栏标签改为显示最近更新时间(相对时间),排序仍然按
   lastTimestampMs 倒序,新会话靠前;
3. messageCount 与 firstUserMessagePreview 由 backend 持久化,
   不再被 slice(-50) 窗口掩盖,CLI/Web 看到的条数一致;
4. 把 left-sidebar-resize 移到 session-sidebar 右侧,
   activity-rail 固定 92px,拖拽控制 session-sidebar 宽度;
5. 侧边栏标签默认显示用户摘要(首条 user message 截断),
   长文本用省略号收尾。

新增 `hwlab-cli client session inspect <conversationId>` 子命令
与 `GET /v1/agent/conversations/{id}` 端点对齐,便于在 CLI 复现
WEB 看到的 session 详情(包含 messageCount / trace / thread)。

Backend 改动:access-control.ts 的 normalizeConversationSnapshot
和 mergeAgentSessionOwnerEvidence 现在按 max 累加 messageCount,
并保留 firstUserMessagePreview;mergeTerminalConversationMessages
返回 { messages, messageCount, firstUserMessagePreview }。
This commit is contained in:
Codex
2026-06-03 11:41:02 +08:00
parent ae1866c6ba
commit 6dbc72deac
7 changed files with 346 additions and 91 deletions
+50 -3
View File
@@ -1006,8 +1006,13 @@ class AccessController {
conversationId: selectedConversationId,
sessionId: selectedAgentSessionId,
threadId,
now
now,
existingMessageCount: existing?.messageCount ?? existing?.snapshot?.messageCount ?? null,
firstUserMessagePreview: existing?.firstUserMessagePreview ?? existing?.snapshot?.firstUserMessagePreview ?? null
});
const mergedMessages = Array.isArray(messages) ? messages : messages?.messages ?? [];
const mergedCount = Array.isArray(messages) ? null : messages?.messageCount ?? null;
const mergedPreview = Array.isArray(messages) ? null : messages?.firstUserMessagePreview ?? null;
return await this.recordAgentSessionOwner({
ownerUserId: actor.id,
sessionId: selectedAgentSessionId,
@@ -1023,7 +1028,9 @@ class AccessController {
sessionStatus,
updatedAt: now,
lastTraceId: activeTraceId,
messages
messages: mergedMessages,
messageCount: mergedCount,
firstUserMessagePreview: mergedPreview
}, actor),
now
});
@@ -1813,6 +1820,14 @@ function mergeAgentSessionOwnerEvidence(nextValue, existingValue) {
const next = normalizeObject(nextValue);
const merged = { ...existing, ...next };
if (!next.agentRun && existing.agentRun) merged.agentRun = existing.agentRun;
const nextCount = numberOrNull(next.messageCount);
const existingCount = numberOrNull(existing.messageCount);
if (nextCount !== null || existingCount !== null) {
merged.messageCount = Math.max(nextCount ?? 0, existingCount ?? 0);
}
if (next.firstUserMessagePreview && !existing.firstUserMessagePreview) {
merged.firstUserMessagePreview = next.firstUserMessagePreview;
}
return merged;
}
function agentSessionIdForRecord(sessionId, traceId) { const sessionText = textOr(sessionId, ""); if (/^ses_[A-Za-z0-9_.:-]+$/u.test(sessionText)) return sessionText; const traceText = textOr(traceId, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(traceText) ? `ses_pending_${traceText.slice(4)}` : null; }
@@ -1927,11 +1942,33 @@ function normalizeConversationSnapshot(body = {}, actor = null) {
...snapshot,
messages: Array.isArray(messagesSource) ? messagesSource.slice(-50).map(redactConversationMessage).filter(Boolean) : [],
chatMessages: Array.isArray(chatMessagesSource) ? chatMessagesSource.slice(-50).map(redactConversationMessage).filter(Boolean) : undefined,
messageCount: resolveSnapshotMessageCount(messagesSource, snapshot.messageCount),
firstUserMessagePreview: firstUserPreviewFromMessages(messagesSource) ?? snapshot.firstUserMessagePreview ?? null,
actor: actor ? publicActor(actor) : undefined,
secretMaterialStored: false,
valuesRedacted: true
};
}
function resolveSnapshotMessageCount(messagesSource, existingCount) {
const explicit = numberOrNull(existingCount);
const fromSource = Array.isArray(messagesSource) ? messagesSource.length : null;
if (explicit !== null && fromSource !== null) return Math.max(explicit, fromSource);
return explicit ?? fromSource ?? 0;
}
function firstUserPreviewFromMessages(messagesSource) {
if (!Array.isArray(messagesSource)) return null;
for (const message of messagesSource) {
if (!message || typeof message !== "object") continue;
if (String(message.role ?? "").toLowerCase() !== "user") continue;
const text = textOr(message.text ?? message.title ?? message.content, "");
if (text) return boundedText(text, 240);
}
return null;
}
function numberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function isTerminalCodeAgentResult(result = {}) {
const status = textOr(result.status, "").toLowerCase();
const terminalStatus = textOr(result.agentRun?.terminalStatus, "").toLowerCase();
@@ -1990,6 +2027,8 @@ function mergeTerminalConversationMessages(existingMessages = [], result = {}, c
const terminalStatus = terminalWorkbenchSessionStatus(result);
const assistantText = textOr(result.reply?.content ?? result.assistantText ?? result.message?.content ?? result.error?.message ?? result.userMessage, "");
const now = textOr(context.now, new Date().toISOString());
const existingCount = numberOrNull(context.existingMessageCount) ?? messages.length;
const firstUserPreview = firstUserPreviewFromMessages(messages) ?? textOr(context.firstUserMessagePreview, null);
const next = messages.map((message) => {
if (traceId && message.traceId === traceId && message.role === "agent") {
return redactConversationMessage({
@@ -2004,6 +2043,7 @@ function mergeTerminalConversationMessages(existingMessages = [], result = {}, c
}
return message;
}).filter(Boolean);
let addedAgentMessage = false;
if (traceId && !next.some((message) => message.traceId === traceId && message.role === "agent")) {
next.push(redactConversationMessage({
id: result.reply?.messageId ?? result.messageId ?? `msg_${traceId.slice(4)}`,
@@ -2018,8 +2058,13 @@ function mergeTerminalConversationMessages(existingMessages = [], result = {}, c
createdAt: result.reply?.createdAt ?? result.createdAt ?? now,
updatedAt: now
}));
addedAgentMessage = true;
}
return next.slice(-50);
return {
messages: next.slice(-50),
messageCount: existingCount + (addedAgentMessage ? 1 : 0),
firstUserMessagePreview: firstUserPreviewFromMessages(next) ?? firstUserPreview
};
}
function redactConversationMessage(message) {
if (!message || typeof message !== "object") return null;
@@ -2077,6 +2122,8 @@ function publicAgentConversation(session) {
endedAt: session.endedAt,
session: pruneEmpty({ sessionId: session.id, threadId: session.threadId, status: session.status }),
messages,
messageCount: numberOrNull(snapshot.messageCount) ?? messages.length,
firstUserMessagePreview: textOr(snapshot.firstUserMessagePreview, null),
snapshot: pruneEmpty({
sessionStatus: snapshot.sessionStatus,
source: snapshot.source,
+30 -2
View File
@@ -123,6 +123,7 @@ function help() {
"hwlab-cli client session create",
"hwlab-cli client session switch cnv_...",
"hwlab-cli client session delete cnv_... --confirm",
"hwlab-cli client session inspect cnv_... [--trace-id trc_...]",
"hwlab-cli client workbench summary --pod-id device-pod-71-freq",
"hwlab-cli client workbench restore --profile NAME",
"hwlab-cli client workbench status --profile NAME",
@@ -654,6 +655,7 @@ async function sessionCommand(context: any) {
if (subcommand === "create" || subcommand === "new") return sessionCreateCommand(context);
if (subcommand === "switch" || subcommand === "select") return sessionSwitchCommand(context);
if (subcommand === "delete" || subcommand === "rm") return sessionDeleteCommand(context);
if (subcommand === "inspect" || subcommand === "describe" || subcommand === "show") return sessionInspectCommand(context);
throw cliError("unsupported_session_command", `unsupported session command: ${subcommand}`, { subcommand });
}
@@ -667,11 +669,32 @@ function sessionHelp() {
"current",
"create [--conversation-id ID] [--session-id ID] [--thread-id ID]",
"switch CONVERSATION_ID|--conversation-id ID",
"delete CONVERSATION_ID|--conversation-id ID --confirm"
"delete CONVERSATION_ID|--conversation-id ID --confirm",
"inspect CONVERSATION_ID|--conversation-id ID [--trace-id TRACE]"
]
});
}
async function sessionInspectCommand(context: any) {
const conversationId = text(context.rest[1]) ?? text(context.parsed.conversationId);
if (!conversationId) {
throw cliError("conversation_id_required", "session inspect requires CONVERSATION_ID or --conversation-id", { usage: "hwlab-cli client session inspect <conversationId>" });
}
const pathName = `/v1/agent/conversations/${encodeURIComponent(conversationId)}`;
const headers = text(context.parsed.traceId) ? { "x-trace-id": text(context.parsed.traceId) } : undefined;
const response = await requestJson({ ...context, method: "GET", path: pathName, extraHeaders: headers });
return responsePayload("client.session.inspect", response, context, {
route: route("GET", pathName),
lookup: clean({
conversationId,
traceId: text(context.parsed.traceId)
}),
summary: sessionSummary(response.body?.conversation),
conversation: response.body?.conversation ?? null,
body: responseBodyForCli(response.body, context.parsed)
});
}
async function sessionListCommand(context: any) {
const projectId = text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID;
const limit = numberOption(context.parsed.limit) ?? 100;
@@ -2179,10 +2202,15 @@ function sessionSummary(conversation: any) {
status: text(conversation.status ?? conversation.snapshot?.sessionStatus),
lastTraceId: text(conversation.lastTraceId),
updatedAt: text(conversation.updatedAt),
messageCount: Array.isArray(conversation.messages) ? conversation.messages.length : undefined,
messageCount: numberOrZero(conversation.messageCount) || (Array.isArray(conversation.messages) ? conversation.messages.length : 0) || undefined,
firstUserMessagePreview: text(conversation.firstUserMessagePreview ?? conversation.snapshot?.firstUserMessagePreview) || undefined,
valuesRedacted: conversation.valuesRedacted === true ? true : undefined
});
}
function numberOrZero(value: any) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function workbenchThreadId(workspace: any, workspaceJson: any) {
const selectedConversation = workspace?.selectedConversation && typeof workspace.selectedConversation === "object" ? workspace.selectedConversation : {};
+42 -2
View File
@@ -38,7 +38,7 @@ export function sessionTabsFromConversations(conversations = [], options = {}) {
.map((tab, index) => ({
...tab,
ordinal: index + 1,
label: tab.title || `Session ${index + 1}`,
label: truncateLabel(tab.userPreview) || tab.title || `Session ${index + 1}`,
subtitle: conversationSessionTabSubtitle(tab),
active: conversationSessionTabMatches(tab, selectedConversationId, selectedSessionId, selectedTraceId)
}));
@@ -89,6 +89,16 @@ export function sessionTabFromConversation(conversation = {}) {
const lastTimestampMs = timestampMs(updatedAt);
const status = normalizedStatus(firstNonEmptyString(conversation.snapshot?.sessionStatus, conversation.status, lastMessage?.status));
const lastTraceId = firstNonEmptyString(conversation.lastTraceId, lastMessage?.traceId, lastMessage?.runnerTrace?.traceId);
const reportedCount = Number(conversation.messageCount);
const messageCount = Number.isFinite(reportedCount) && reportedCount >= 0 ? reportedCount : messages.length;
const userPreviewSource = firstNonEmptyString(
conversation.firstUserMessagePreview,
conversation.snapshot?.firstUserMessagePreview
);
const userPreview = userPreviewSource || firstNonEmptyString(
messages.find((message) => String(message?.role ?? "").toLowerCase() === "user")?.text,
messages.find((message) => String(message?.role ?? "").toLowerCase() === "user")?.title
);
return {
key: sessionId || conversationId,
conversationId,
@@ -97,7 +107,8 @@ export function sessionTabFromConversation(conversation = {}) {
lastTraceId,
status,
title: conversationSessionTitle(conversation, sessionId, conversationId),
messageCount: messages.length,
messageCount,
userPreview: userPreview ? String(userPreview) : null,
lastTimestampMs,
updatedAt,
lastMessagePreview: firstNonEmptyString(lastMessage?.text, lastMessage?.title, conversation.snapshot?.source)
@@ -187,13 +198,42 @@ function conversationSessionTitle(conversation, sessionId, conversationId) {
const token = shortToken(sessionId || conversationId);
return `Session ${token}`;
}
function truncateLabel(text) {
const value = String(text ?? "").replace(/\s+/gu, " ").trim();
if (!value) return "";
if (value.length <= 36) return value;
return `${value.slice(0, 36).trimEnd()}`;
}
function conversationSessionTabSubtitle(tab) {
const relative = relativeUpdatedLabel(tab.lastTimestampMs);
const status = tabSubtitleStatus(tab);
if (relative && status) return `${relative} · ${status}`;
return status || relative || "";
}
function tabSubtitleStatus(tab) {
if (["running", "busy", "pending", "active"].includes(tab.status)) return "处理中";
if (tab.lastTraceId) return `trace ${shortToken(tab.lastTraceId)}`;
if (tab.threadId) return `thread ${shortToken(tab.threadId)}`;
return "未开始";
}
function relativeUpdatedLabel(timestampMs) {
if (!Number.isFinite(timestampMs) || timestampMs <= 0) return "";
const delta = Date.now() - timestampMs;
if (delta < 0) return "刚刚";
if (delta < 60_000) return `${Math.max(1, Math.round(delta / 1000))}s 前`;
if (delta < 3_600_000) return `${Math.round(delta / 60_000)}m 前`;
if (delta < 86_400_000) return `${Math.round(delta / 3_600_000)}h 前`;
if (delta < 30 * 86_400_000) return `${Math.round(delta / 86_400_000)}d 前`;
return formatDateLabel(timestampMs);
}
function formatDateLabel(timestampMs) {
try {
return new Date(timestampMs).toISOString().slice(0, 10);
} catch {
return "";
}
}
function conversationSessionTabMatches(tab, conversationId, sessionId, traceId) {
const values = [tab.key, tab.conversationId, tab.sessionId, tab.lastTraceId].filter(Boolean);
+118 -56
View File
@@ -100,6 +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_MIN_WIDTH = 148;
const SESSION_SIDEBAR_MAX_WIDTH = 320;
const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728;
const RIGHT_SIDEBAR_MIN_WIDTH = 560;
const RIGHT_SIDEBAR_MAX_WIDTH = 740;
@@ -116,8 +119,9 @@ const el = {
logoutButton: byId("logout-button"),
leftSidebarToggle: byId("left-sidebar-toggle"),
rightSidebarToggle: byId("right-sidebar-toggle"),
leftSidebarResize: byId("left-sidebar-resize"),
sessionSidebarResize: byId("session-sidebar-resize"),
rightSidebarResize: byId("right-sidebar-resize"),
copySessionId: byId("copy-session-id"),
routePath: byId("route-path"),
liveStatus: byId("live-status"),
liveDetail: byId("live-detail"),
@@ -259,8 +263,8 @@ const state = {
},
layout: {
leftSidebarCollapsed: false,
leftSidebarWidth: LEFT_SIDEBAR_DEFAULT_WIDTH,
leftDrag: null,
sessionSidebarWidth: SESSION_SIDEBAR_DEFAULT_WIDTH,
sessionSidebarDrag: null,
rightSidebarCollapsed: false,
rightSidebarWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH,
rightDrag: null
@@ -278,11 +282,12 @@ initRoutes();
initLayoutSizing();
initLeftSidebarToggle();
initRightSidebarToggle();
initLeftSidebarResize();
initSessionSidebarResize();
initRightSidebarResize();
initDevicePodPanel();
initSkillsPanel();
initSessionSidebar();
initCopySessionIdButton();
initCodeAgentTimeoutControl();
initConversationScrollMemory();
initCommandBar();
@@ -623,10 +628,67 @@ function renderSessionSidebar() {
const pending = Boolean(state.sessionList.pendingAction);
el.sessionCreate.disabled = pending;
el.sessionDelete.disabled = pending || !view.activeConversation?.conversationId;
renderCopySessionIdButton(view.activeConversation);
el.sessionStatus.textContent = sessionSidebarStatusText(view.tabs.length);
el.sessionStatus.title = state.sessionList.error || state.sessionList.loadedAt || "session list not loaded";
}
function renderCopySessionIdButton(activeConversation) {
if (!el.copySessionId) return;
const conversationId = textOf(activeConversation?.conversationId) || textOf(state.conversationId);
el.copySessionId.hidden = !conversationId;
el.copySessionId.dataset.conversationId = conversationId || "";
el.copySessionId.dataset.sessionId = textOf(activeConversation?.sessionId ?? state.sessionId) || "";
el.copySessionId.classList.remove("is-copied");
el.copySessionId.textContent = "复制 session id";
}
function textOf(value) {
const text = String(value ?? "").trim();
return text || null;
}
function initCopySessionIdButton() {
if (!el.copySessionId) return;
el.copySessionId.addEventListener("click", async () => {
const conversationId = textOf(el.copySessionId.dataset.conversationId);
if (!conversationId) return;
const ok = await copyTextToClipboard(conversationId);
el.copySessionId.classList.toggle("is-copied", ok);
el.copySessionId.textContent = ok ? "已复制" : "复制失败";
setTimeout(() => {
el.copySessionId.classList.remove("is-copied");
el.copySessionId.textContent = "复制 session id";
}, 1400);
});
}
async function copyTextToClipboard(value) {
if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {
// fall through to the legacy path below
}
}
if (typeof document === "undefined") return false;
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "true");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
document.body.removeChild(textarea);
return ok;
}
function sessionSidebarConversations() {
const items = Array.isArray(state.sessionList.items) ? [...state.sessionList.items] : [];
if (state.conversationId && !items.some((item) => item?.conversationId === state.conversationId)) {
@@ -1130,9 +1192,9 @@ function initRoutes() {
function initLayoutSizing() {
const persisted = readPersistedLayout();
const leftSidebarWidth = clampLeftSidebarWidth(persisted?.leftSidebarWidth ?? LEFT_SIDEBAR_DEFAULT_WIDTH);
const sessionSidebarWidth = clampSessionSidebarWidth(persisted?.sessionSidebarWidth ?? SESSION_SIDEBAR_DEFAULT_WIDTH);
const rightSidebarWidth = clampRightSidebarWidth(persisted?.rightSidebarWidth ?? RIGHT_SIDEBAR_DEFAULT_WIDTH);
setLeftSidebarWidth(leftSidebarWidth, { persist: false });
setSessionSidebarWidth(sessionSidebarWidth, { persist: false });
setLeftSidebarCollapsed(persisted?.leftSidebarCollapsed === true, { persist: false });
setRightSidebarWidth(rightSidebarWidth, { persist: false });
setRightSidebarCollapsed(persisted?.rightSidebarCollapsed === true, { persist: false });
@@ -1146,7 +1208,7 @@ function readPersistedLayout() {
if (payload?.version !== 1) return null;
return {
leftSidebarCollapsed: payload.leftSidebarCollapsed === true,
leftSidebarWidth: Number(payload.leftSidebarWidth),
sessionSidebarWidth: Number(payload.sessionSidebarWidth ?? payload.leftSidebarWidth),
rightSidebarCollapsed: payload.rightSidebarCollapsed === true,
rightSidebarWidth: Number(payload.rightSidebarWidth)
};
@@ -1162,7 +1224,7 @@ function persistLayout() {
JSON.stringify({
version: 1,
leftSidebarCollapsed: state.layout.leftSidebarCollapsed,
leftSidebarWidth: state.layout.leftSidebarWidth,
sessionSidebarWidth: state.layout.sessionSidebarWidth,
rightSidebarCollapsed: state.layout.rightSidebarCollapsed,
rightSidebarWidth: state.layout.rightSidebarWidth
})
@@ -1188,7 +1250,7 @@ function setLeftSidebarCollapsed(collapsed, options = {}) {
);
el.leftSidebarToggle.title = state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航";
el.leftSidebarToggle.textContent = state.layout.leftSidebarCollapsed ? "展开" : "收起";
syncLeftSidebarResizeA11y();
syncSessionSidebarResizeA11y();
if (options.persist !== false) persistLayout();
}
@@ -1212,11 +1274,11 @@ function setRightSidebarCollapsed(collapsed, options = {}) {
if (options.persist !== false) persistLayout();
}
function setLeftSidebarWidth(width, options = {}) {
const clamped = clampLeftSidebarWidth(width);
state.layout.leftSidebarWidth = clamped;
el.shell.style.setProperty("--left-sidebar-width", `${clamped}px`);
syncLeftSidebarResizeA11y();
function setSessionSidebarWidth(width, options = {}) {
const clamped = clampSessionSidebarWidth(width);
state.layout.sessionSidebarWidth = clamped;
el.shell.style.setProperty("--session-sidebar-width", `${clamped}px`);
syncSessionSidebarResizeA11y();
if (options.persist !== false) persistLayout();
return clamped;
}
@@ -1230,10 +1292,10 @@ function setRightSidebarWidth(width, options = {}) {
return clamped;
}
function clampLeftSidebarWidth(value) {
function clampSessionSidebarWidth(value) {
const numeric = Number(value);
const width = Number.isFinite(numeric) ? numeric : LEFT_SIDEBAR_DEFAULT_WIDTH;
const bounds = leftSidebarResizeBounds();
const width = Number.isFinite(numeric) ? numeric : SESSION_SIDEBAR_DEFAULT_WIDTH;
const bounds = sessionSidebarResizeBounds();
return Math.min(Math.max(Math.round(width), bounds.min), bounds.max);
}
@@ -1250,16 +1312,16 @@ function boundedResizeRange(min, max, dynamicMax) {
return { min: safeMin, max: safeMax };
}
function leftSidebarResizeBounds() {
function sessionSidebarResizeBounds() {
const styles = getComputedStyle(el.shell);
const cssMin = readCssPixel(styles.getPropertyValue("--left-sidebar-min-width"), LEFT_SIDEBAR_MIN_WIDTH);
const cssMax = readCssPixel(styles.getPropertyValue("--left-sidebar-max-width"), LEFT_SIDEBAR_MAX_WIDTH);
const cssMin = readCssPixel(styles.getPropertyValue("--session-sidebar-min-width"), SESSION_SIDEBAR_MIN_WIDTH);
const cssMax = readCssPixel(styles.getPropertyValue("--session-sidebar-max-width"), SESSION_SIDEBAR_MAX_WIDTH);
if (window.matchMedia("(max-width: 1240px)").matches) {
const fixedRailWidth = readCssPixel(styles.getPropertyValue("--rail-width"), LEFT_SIDEBAR_DEFAULT_WIDTH);
return { min: Math.round(fixedRailWidth), max: Math.round(fixedRailWidth) };
const fixedSessionWidth = readCssPixel(styles.getPropertyValue("--rail-width"), SESSION_SIDEBAR_DEFAULT_WIDTH);
return { min: SESSION_SIDEBAR_MIN_WIDTH, max: SESSION_SIDEBAR_MAX_WIDTH };
}
const rightWidth = state.layout.rightSidebarWidth;
const dynamicMax = window.innerWidth - rightWidth - 420 - 4;
const dynamicMax = window.innerWidth - rightWidth - LEFT_SIDEBAR_DEFAULT_WIDTH - 420 - 4;
return boundedResizeRange(cssMin, cssMax, dynamicMax);
}
@@ -1272,7 +1334,7 @@ function rightSidebarResizeBounds() {
}
const railWidth = state.layout.leftSidebarCollapsed
? readCssPixel(styles.getPropertyValue("--rail-collapsed-width"), LEFT_SIDEBAR_DEFAULT_WIDTH)
: state.layout.leftSidebarWidth;
: LEFT_SIDEBAR_DEFAULT_WIDTH;
const dynamicMax = window.innerWidth - railWidth - 420 - 4;
return boundedResizeRange(cssMin, cssMax, dynamicMax);
}
@@ -1294,16 +1356,16 @@ function syncRightSidebarResizeA11y() {
el.rightSidebarResize.setAttribute("aria-valuetext", `右侧 Device Pod 看板宽度 ${state.layout.rightSidebarWidth} 像素`);
}
function syncLeftSidebarResizeA11y() {
const bounds = leftSidebarResizeBounds();
const disabled = !canResizeLeftSidebar();
el.leftSidebarResize.tabIndex = disabled ? -1 : 0;
el.leftSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false");
el.leftSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false");
el.leftSidebarResize.setAttribute("aria-valuemin", String(bounds.min));
el.leftSidebarResize.setAttribute("aria-valuemax", String(bounds.max));
el.leftSidebarResize.setAttribute("aria-valuenow", String(state.layout.leftSidebarWidth));
el.leftSidebarResize.setAttribute("aria-valuetext", `左侧导航宽度 ${state.layout.leftSidebarWidth} 像素`);
function syncSessionSidebarResizeA11y() {
const bounds = sessionSidebarResizeBounds();
const disabled = !canResizeSessionSidebar();
el.sessionSidebarResize.tabIndex = disabled ? -1 : 0;
el.sessionSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false");
el.sessionSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false");
el.sessionSidebarResize.setAttribute("aria-valuemin", String(bounds.min));
el.sessionSidebarResize.setAttribute("aria-valuemax", String(bounds.max));
el.sessionSidebarResize.setAttribute("aria-valuenow", String(state.layout.sessionSidebarWidth));
el.sessionSidebarResize.setAttribute("aria-valuetext", `Session sidebar 宽度 ${state.layout.sessionSidebarWidth} 像素`);
}
function routeFromLocation() {
@@ -1333,31 +1395,31 @@ function showView(route) {
}
}
function initLeftSidebarResize() {
function initSessionSidebarResize() {
window.addEventListener("resize", () => {
if (canResizeLeftSidebar()) setLeftSidebarWidth(state.layout.leftSidebarWidth, { persist: false });
syncLeftSidebarResizeA11y();
if (canResizeSessionSidebar()) setSessionSidebarWidth(state.layout.sessionSidebarWidth, { persist: false });
syncSessionSidebarResizeA11y();
});
el.leftSidebarResize.addEventListener("pointerdown", (event) => {
if (!canResizeLeftSidebar()) return;
el.sessionSidebarResize.addEventListener("pointerdown", (event) => {
if (!canResizeSessionSidebar()) return;
event.preventDefault();
el.leftSidebarResize.setPointerCapture?.(event.pointerId);
state.layout.leftDrag = {
el.sessionSidebarResize.setPointerCapture?.(event.pointerId);
state.layout.sessionSidebarDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: state.layout.leftSidebarWidth
startWidth: state.layout.sessionSidebarWidth
};
el.shell.classList.add("is-resizing");
});
el.leftSidebarResize.addEventListener("pointermove", (event) => {
const drag = state.layout.leftDrag;
el.sessionSidebarResize.addEventListener("pointermove", (event) => {
const drag = state.layout.sessionSidebarDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
setLeftSidebarWidth(drag.startWidth + event.clientX - drag.startX, { persist: false });
setSessionSidebarWidth(drag.startWidth + event.clientX - drag.startX, { persist: false });
});
el.leftSidebarResize.addEventListener("pointerup", finishLeftSidebarResize);
el.leftSidebarResize.addEventListener("pointercancel", finishLeftSidebarResize);
el.leftSidebarResize.addEventListener("keydown", (event) => {
if (!canResizeLeftSidebar()) return;
el.sessionSidebarResize.addEventListener("pointerup", finishSessionSidebarResize);
el.sessionSidebarResize.addEventListener("pointercancel", finishSessionSidebarResize);
el.sessionSidebarResize.addEventListener("keydown", (event) => {
if (!canResizeSessionSidebar()) return;
const keySteps = {
ArrowLeft: -8,
ArrowRight: 8,
@@ -1366,26 +1428,26 @@ function initLeftSidebarResize() {
};
if (Object.hasOwn(keySteps, event.key)) {
event.preventDefault();
setLeftSidebarWidth(state.layout.leftSidebarWidth + keySteps[event.key]);
setSessionSidebarWidth(state.layout.sessionSidebarWidth + keySteps[event.key]);
} else if (event.key === "Home") {
event.preventDefault();
setLeftSidebarWidth(leftSidebarResizeBounds().min);
setSessionSidebarWidth(sessionSidebarResizeBounds().min);
} else if (event.key === "End") {
event.preventDefault();
setLeftSidebarWidth(leftSidebarResizeBounds().max);
setSessionSidebarWidth(sessionSidebarResizeBounds().max);
}
});
}
function finishLeftSidebarResize(event) {
const drag = state.layout.leftDrag;
function finishSessionSidebarResize(event) {
const drag = state.layout.sessionSidebarDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.layout.leftDrag = null;
state.layout.sessionSidebarDrag = null;
el.shell.classList.remove("is-resizing");
persistLayout();
}
function canResizeLeftSidebar() {
function canResizeSessionSidebar() {
return !state.layout.leftSidebarCollapsed && !window.matchMedia("(max-width: 1240px)").matches;
}
+17 -15
View File
@@ -48,20 +48,6 @@
title="折叠左侧导航"
>收起</button>
</aside>
<div
class="resize-handle left-sidebar-resize"
id="left-sidebar-resize"
role="separator"
tabindex="0"
aria-label="拖拽调整左侧导航宽度"
aria-controls="activity-rail"
aria-orientation="vertical"
aria-valuemin="72"
aria-valuemax="180"
aria-valuenow="92"
title="拖拽调整左侧导航宽度;用左右方向键微调,Home/End 到最小或最大。"
></div>
<aside class="session-sidebar" id="session-sidebar" aria-label="Code Agent sessions">
<header class="session-sidebar-head">
<div>
@@ -76,6 +62,19 @@
<button class="session-delete-button" id="session-delete" type="button" disabled>删除当前</button>
</div>
</aside>
<div
class="resize-handle session-sidebar-resize"
id="session-sidebar-resize"
role="separator"
tabindex="0"
aria-label="拖拽调整 Session sidebar 宽度"
aria-controls="session-sidebar"
aria-orientation="vertical"
aria-valuemin="148"
aria-valuemax="320"
aria-valuenow="220"
title="拖拽调整 Session sidebar 宽度;用左右方向键微调,Home/End 到最小或最大。"
></div>
<section class="center-workspace">
<section class="view workbench-view" id="workspace" data-view="workspace" aria-labelledby="workspace-title">
@@ -86,7 +85,10 @@
<p class="eyebrow">Agent 工作区</p>
<h2 id="workspace-title">Agent 对话</h2>
</div>
<span class="state-tag tone-source" id="agent-chat-status">等待输入</span>
<div class="panel-title-actions">
<button class="icon-button" id="copy-session-id" type="button" hidden aria-label="复制 session id" title="复制 session id 到剪贴板,便于在 HWLAB CLI 复现。">复制 session id</button>
<span class="state-tag tone-source" id="agent-chat-status">等待输入</span>
</div>
</div>
<details class="code-agent-summary tone-border-pending" id="code-agent-summary">
<summary>
+48 -2
View File
@@ -64,7 +64,7 @@ test("session tabs use account conversations and keep the selected session activ
assert.equal(view.tabs.length, 2);
assert.equal(view.activeConversation?.conversationId, "cnv_old");
assert.equal(view.tabs[0].conversationId, "cnv_new");
assert.equal(view.tabs.find((tab) => tab.conversationId === "cnv_new")?.subtitle, "处理中");
assert.match(view.tabs.find((tab) => tab.conversationId === "cnv_new")?.subtitle ?? "", /处理中/);
});
test("session tabs default to newest visible conversation", () => {
@@ -77,6 +77,51 @@ test("session tabs default to newest visible conversation", () => {
assert.equal(view.activeKey, "ses_b");
});
test("session tabs surface authoritative messageCount and fall back to message length", () => {
const explicit = sessionTabsFromConversations([
conversation("cnv_explicit", "ses_explicit", "trc_explicit", "completed", "2026-06-03T00:00:01Z", {
messageCount: 99,
firstUserMessagePreview: "记住暗号:33304"
})
]);
assert.equal(explicit.tabs[0].messageCount, 99);
assert.equal(explicit.tabs[0].userPreview, "记住暗号:33304");
assert.match(explicit.tabs[0].label, /记住暗号:33304/);
const fallback = sessionTabsFromConversations([
conversation("cnv_fb", "ses_fb", "trc_fb", "completed", "2026-06-03T00:00:01Z")
]);
assert.equal(fallback.tabs[0].messageCount, 1);
assert.equal(fallback.tabs[0].userPreview, null);
});
test("session tab subtitle prepends a relative updated-at label", () => {
const recent = new Date(Date.now() - 90_000).toISOString();
const view = sessionTabsFromConversations([
conversation("cnv_recent", "ses_recent", "trc_recent", "completed", recent)
]);
assert.match(view.tabs[0].subtitle, /^\d+m 前 · /);
assert.match(view.tabs[0].subtitle, /trace recent/);
const stale = new Date("2024-01-01T00:00:00Z").toISOString();
const oldView = sessionTabsFromConversations([
conversation("cnv_old", "ses_old", "trc_old", "completed", stale)
]);
assert.match(oldView.tabs[0].subtitle, /^2024-01-01 · /);
});
test("session tab label prefers user preview and truncates long input", () => {
const longPreview = "这是一段超过三十六字符的用户摘要需要被截断显示在 sidebar tab 上";
const view = sessionTabsFromConversations([
conversation("cnv_long", "ses_long", "trc_long", "completed", "2026-06-03T00:00:01Z", {
messageCount: 1,
firstUserMessagePreview: longPreview
})
]);
assert.ok(view.tabs[0].label.length <= 37, `label too long: ${view.tabs[0].label}`);
assert.ok(view.tabs[0].label.endsWith("…"));
});
function message(role, sessionId, conversationId, traceId, status, createdAt) {
return {
id: `${role}_${traceId}`,
@@ -91,13 +136,14 @@ function message(role, sessionId, conversationId, traceId, status, createdAt) {
};
}
function conversation(conversationId, sessionId, traceId, status, updatedAt) {
function conversation(conversationId, sessionId, traceId, status, updatedAt, extras = {}) {
return {
conversationId,
sessionId,
status,
updatedAt,
lastTraceId: traceId,
...extras,
messages: [{ role: "agent", text: traceId, status, traceId, updatedAt }]
};
}
+41 -11
View File
@@ -88,8 +88,8 @@ ul {
}
.workbench-shell {
--rail-width: clamp(var(--left-sidebar-min-width), var(--left-sidebar-width), var(--left-sidebar-max-width));
--session-sidebar-width: clamp(174px, 17vw, 236px);
--rail-width: 92px;
--session-sidebar-width: clamp(148px, 18vw, 320px);
--right-width: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
--right-width-expanded: clamp(var(--right-sidebar-min-width), var(--right-sidebar-width), var(--right-sidebar-max-width));
position: relative;
@@ -498,25 +498,21 @@ body > [data-app-shell][hidden] {
opacity: 0.56;
}
.left-sidebar-resize {
.session-sidebar-resize {
top: 0;
left: calc(var(--rail-width) - 6px);
left: calc(var(--rail-width) + var(--session-sidebar-width) - 6px);
width: 12px;
height: 100%;
cursor: col-resize;
}
.left-sidebar-resize::before {
.session-sidebar-resize::before {
top: 10px;
bottom: 10px;
left: 5px;
width: 2px;
}
.workbench-shell.is-left-sidebar-collapsed .left-sidebar-resize {
display: none;
}
.workbench-shell.is-right-sidebar-collapsed .right-sidebar-resize {
display: none;
}
@@ -623,6 +619,40 @@ h3 {
gap: 8px;
min-width: 0;
}
.panel-title-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.icon-button {
min-height: 26px;
padding: 4px 10px;
border: 1px solid var(--line-strong);
background: var(--surface-2);
color: var(--text);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
border-radius: 4px;
transition: border-color 0.15s ease, color 0.15s ease;
}
.icon-button:hover,
.icon-button:focus-visible {
border-color: var(--accent);
color: var(--accent);
outline: 0;
}
.icon-button[hidden] {
display: none;
}
.icon-button.is-copied {
border-color: var(--ok);
color: var(--ok);
}
.skills-panel {
min-height: 0;
@@ -2521,7 +2551,7 @@ tbody tr:last-child td {
border-left: 0;
}
.left-sidebar-resize,
.session-sidebar-resize,
.right-sidebar-resize {
display: none;
}
@@ -2551,7 +2581,7 @@ tbody tr:last-child td {
min-height: 36px;
}
.left-sidebar-resize,
.session-sidebar-resize,
.right-sidebar-resize {
display: none;
}