Merge pull request #743 from pikasTech/fix/issue740-webui-session-mgmt
fix(issue740): HWLAB v0.2 WEBUI session management improvements
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -2185,10 +2208,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 : {};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user