diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index f85f6cd9..7efa11f3 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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, diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index f2f74891..55f8f272 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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 " }); + } + 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 : {}; diff --git a/web/hwlab-cloud-web/app-session-tabs.ts b/web/hwlab-cloud-web/app-session-tabs.ts index 08d24ade..f4e5124c 100644 --- a/web/hwlab-cloud-web/app-session-tabs.ts +++ b/web/hwlab-cloud-web/app-session-tabs.ts @@ -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); diff --git a/web/hwlab-cloud-web/app.ts b/web/hwlab-cloud-web/app.ts index 6515e049..afb3496c 100644 --- a/web/hwlab-cloud-web/app.ts +++ b/web/hwlab-cloud-web/app.ts @@ -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; } diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 77eb8b38..e8038a2a 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -48,20 +48,6 @@ title="折叠左侧导航" >收起 - - +
@@ -86,7 +85,10 @@

Agent 工作区

Agent 对话

- 等待输入 +
+ + 等待输入 +
diff --git a/web/hwlab-cloud-web/session-tabs.test.ts b/web/hwlab-cloud-web/session-tabs.test.ts index 12f83397..d9e6106c 100644 --- a/web/hwlab-cloud-web/session-tabs.test.ts +++ b/web/hwlab-cloud-web/session-tabs.test.ts @@ -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 }] }; } diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 84220607..73d22220 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -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; }