From c5bd542c3058644209942f9afff4e2cb656319b8 Mon Sep 17 00:00:00 2001 From: lyon Date: Tue, 16 Jun 2026 07:03:00 +0800 Subject: [PATCH] fix: make workbench session timestamps user-driven --- internal/cloud/access-control.ts | 31 ++++++++++++- .../workbench-r2-session-parity.test.ts | 46 +++++++++++++++---- .../src/components/workbench/SessionRail.vue | 2 +- .../src/stores/workbench-session.ts | 39 ++++++++++------ web/hwlab-cloud-web/src/stores/workbench.ts | 7 ++- web/hwlab-cloud-web/src/styles/workbench.css | 33 +++++++++++++ web/hwlab-cloud-web/src/types/index.ts | 2 + 7 files changed, 133 insertions(+), 27 deletions(-) diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index bcb41feb..2878fa3e 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -2365,12 +2365,14 @@ function normalizeConversationSnapshot(body = {}, actor = null) { const chatMessagesSource = Array.isArray(body.chatMessages) ? body.chatMessages : snapshot.chatMessages; const messages = dedupeConversationMessages(messagesSource); const chatMessages = Array.isArray(chatMessagesSource) ? dedupeConversationMessages(chatMessagesSource) : undefined; + const lastUserMessageAt = lastUserMessageAtFromMessages(messages); return { ...snapshot, messages, chatMessages, messageCount: resolveSnapshotMessageCount(messages, snapshot.messageCount), firstUserMessagePreview: firstUserPreviewFromMessages(messages) ?? snapshot.firstUserMessagePreview ?? null, + lastUserMessageAt: lastUserMessageAt ?? textOr(snapshot.lastUserMessageAt, null), actor: actor ? publicActor(actor) : undefined, secretMaterialStored: false, valuesRedacted: true @@ -2392,6 +2394,27 @@ function firstUserPreviewFromMessages(messagesSource) { } return null; } +function lastUserMessageAtFromMessages(messagesSource) { + if (!Array.isArray(messagesSource)) return null; + let latest = null; + for (const message of messagesSource) { + if (!message || typeof message !== "object") continue; + if (String(message.role ?? "").toLowerCase() !== "user") continue; + latest = latestIso(latest, message.createdAt, message.updatedAt); + } + return latest; +} +function latestIso(...values) { + let latest = null; + for (const value of values) { + const text = textOr(value, ""); + if (!text) continue; + const ms = Date.parse(text); + if (!Number.isFinite(ms)) continue; + if (!latest || ms > latest.ms) latest = { value: text, ms }; + } + return latest?.value ?? null; +} function numberOrNull(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; @@ -2670,6 +2693,8 @@ function publicAgentConversation(session) { const displayMessages = normalizeFinalResponseConversationMessages(messages, snapshot.finalResponse); const status = resolvedConversationStatus(session.status, snapshot, displayMessages); const lastTraceId = resolvedConversationLastTraceId(session.lastTraceId, status, displayMessages, snapshot); + const lastUserMessageAt = lastUserMessageAtFromMessages(displayMessages) ?? textOr(snapshot.lastUserMessageAt, null); + const displayUpdatedAt = lastUserMessageAt ?? session.startedAt ?? session.updatedAt; return { conversationId: session.conversationId, sessionId: session.id, @@ -2679,7 +2704,8 @@ function publicAgentConversation(session) { agentId: session.agentId, ownerUserId: session.ownerUserId, lastTraceId, - updatedAt: session.updatedAt, + updatedAt: displayUpdatedAt, + lastUserMessageAt, startedAt: session.startedAt, endedAt: session.endedAt, session: pruneEmpty({ sessionId: session.id, threadId: session.threadId, status }), @@ -2689,7 +2715,8 @@ function publicAgentConversation(session) { snapshot: pruneEmpty({ sessionStatus: snapshot.sessionStatus, source: snapshot.source, - updatedAt: snapshot.updatedAt, + updatedAt: displayUpdatedAt, + lastUserMessageAt, valuesRedacted: snapshot.valuesRedacted !== false }), valuesRedacted: true diff --git a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts index e47032d1..58203d45 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r2-session-parity.test.ts @@ -61,20 +61,46 @@ test("R2 session tabs expose label, model metadata source, count and active stat assert.match(tabs[0]?.subtitle ?? "", /trace trc_done/u); }); -test("R2 session tabs use latest message activity time instead of stale conversation time", () => { +test("R2 session tabs use last user message time instead of agent or trace activity", () => { const tabs = sortSessionTabs([ { - conversationId: "cnv_old_created_recent_message", - sessionId: "ses_old_created_recent_message", - firstUserMessagePreview: "三天前创建但刚刚更新", + conversationId: "cnv_old_user_recent_agent", + sessionId: "ses_old_user_recent_agent", + firstUserMessagePreview: "用户消息较早但 agent 刚刚更新", updatedAt: "2026-01-01T00:00:00.000Z", startedAt: "2026-01-01T00:00:00.000Z", - messages: [agentMessage({ traceId: "trc_recent", conversationId: "cnv_old_created_recent_message", updatedAt: "2026-01-04T00:01:00.000Z" })] + messages: [ + userMessage({ conversationId: "cnv_old_user_recent_agent", createdAt: "2026-01-01T00:02:00.000Z" }), + agentMessage({ traceId: "trc_recent", conversationId: "cnv_old_user_recent_agent", updatedAt: "2026-01-04T00:01:00.000Z" }) + ] }, - { conversationId: "cnv_newer_created", sessionId: "ses_newer_created", firstUserMessagePreview: "创建时间较新但未更新", updatedAt: "2026-01-03T00:00:00.000Z" } + { + conversationId: "cnv_newer_user", + sessionId: "ses_newer_user", + firstUserMessagePreview: "用户消息较新", + updatedAt: "2026-01-03T00:00:00.000Z", + messages: [userMessage({ conversationId: "cnv_newer_user", createdAt: "2026-01-03T00:00:00.000Z" })] + } ], null); - assert.equal(tabs[0]?.conversationId, "cnv_old_created_recent_message"); - assert.equal(tabs[0]?.updatedAt, "2026-01-04T00:01:00.000Z"); + assert.equal(tabs[0]?.conversationId, "cnv_newer_user"); + assert.equal(tabs.find((tab) => tab.conversationId === "cnv_old_user_recent_agent")?.updatedAt, "2026-01-01T00:02:00.000Z"); +}); + +test("R2 running session tabs expose animation state without rewriting label", () => { + const tabs = sortSessionTabs([ + { + conversationId: "cnv_running", + sessionId: "ses_running", + firstUserMessagePreview: "保持原始标题", + status: "busy", + messages: [ + userMessage({ conversationId: "cnv_running", createdAt: "2026-01-04T00:00:00.000Z" }), + agentMessage({ conversationId: "cnv_running", status: "running", updatedAt: "2026-01-04T00:01:00.000Z" }) + ] + } + ], null); + assert.equal(tabs[0]?.label, "保持原始标题"); + assert.equal(tabs[0]?.running, true); }); test("R2 session tabs keep the workspace-selected conversation when the list window omits it", () => { @@ -139,3 +165,7 @@ function workspaceRecord(input: { activeTraceId: string | null; sessionStatus: s function agentMessage(patch: Partial): ChatMessage { return { id: "msg_r2", role: "agent", title: "Code Agent", text: "running", status: "running", createdAt: "2026-01-01T00:00:00.000Z", ...patch }; } + +function userMessage(patch: Partial): ChatMessage { + return { id: `msg_user_${patch.conversationId ?? "r2"}`, role: "user", title: "用户", text: "prompt", status: "sent", createdAt: "2026-01-01T00:00:00.000Z", ...patch }; +} diff --git a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue index bf8703d2..0df01f51 100644 --- a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue +++ b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue @@ -83,7 +83,7 @@ function formatSessionUpdatedTime(value: string | null | undefined): string {
模型通道:{{ workbench.providerProfile }}
- diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 125b437a..632c7955 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -17,6 +17,7 @@ export interface SessionTab extends ConversationRecord { label: string; subtitle: string; active: boolean; + running: boolean; } export interface DraftEntry { @@ -115,10 +116,11 @@ export function workspaceWithClearedActiveTrace(workspace: WorkspaceRecord | nul export function conversationToSessionTab(conversation: ConversationRecord, activeConversationId: string | null): SessionTab { const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId); const conversationId = conversation.conversationId; - const updatedAt = conversationActivityUpdatedAt(conversation); + const updatedAt = conversationDisplayUpdatedAt(conversation); const status = firstNonEmptyString(conversation.snapshot?.sessionStatus, conversation.status, conversation.messages?.at(-1)?.status) ?? "source"; const trace = firstNonEmptyString(conversation.lastTraceId, conversation.messages?.at(-1)?.traceId); const userMessage = conversation.messages?.find((message) => message.role === "user"); + const running = isActiveStatus(status); const preview = firstReadableSentence( conversation.firstUserMessagePreview, conversation.snapshot?.firstUserMessagePreview, @@ -138,6 +140,7 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ lastTraceId: trace, status, active: conversationId === activeConversationId, + running, updatedAt }; } @@ -189,13 +192,15 @@ function selectedConversationStub(workspace: WorkspaceRecord | null, selectedCon const sessionId = firstNonEmptyString(workspace.selectedAgentSessionId, workspace.workspace?.selectedAgentSessionId, messages.find((message) => message.sessionId)?.sessionId) ?? null; const threadId = firstNonEmptyString(workspace.workspace?.threadId, messages.find((message) => message.threadId)?.threadId) ?? null; const lastTraceId = firstNonEmptyString(workspace.workspace?.lastTraceId, workspace.activeTraceId, workspace.workspace?.activeTraceId, [...messages].reverse().find((message) => message.traceId)?.traceId) ?? null; + const lastUserMessageAt = latestUserMessageAtFromMessages(messages); return { conversationId: selectedConversationId, projectId: firstNonEmptyString(workspace.projectId, workspace.workspace?.projectId) ?? null, sessionId, threadId, status: firstNonEmptyString(workspace.workspace?.sessionStatus, messages.at(-1)?.status) ?? null, - updatedAt: latestTimestamp(workspace.updatedAt, workspace.workspace?.updatedAt, ...messages.flatMap((message) => [message.updatedAt, message.runnerTrace?.updatedAt, message.createdAt])), + updatedAt: lastUserMessageAt ?? firstNonEmptyString(workspace.createdAt, workspace.updatedAt, workspace.workspace?.updatedAt), + lastUserMessageAt, lastTraceId, messageCount: messages.length, messages @@ -205,6 +210,7 @@ function selectedConversationStub(workspace: WorkspaceRecord | null, selectedCon function mergeConversationRecords(existing: ConversationRecord, selected: ConversationRecord): ConversationRecord { const selectedMessages = selected.messages ?? []; const existingMessages = existing.messages ?? []; + const lastUserMessageAt = firstNonEmptyString(selected.lastUserMessageAt, selected.snapshot?.lastUserMessageAt, latestUserMessageAtFromMessages(selectedMessages), existing.lastUserMessageAt, existing.snapshot?.lastUserMessageAt, latestUserMessageAtFromMessages(existingMessages)) ?? null; return { ...existing, ...selected, @@ -217,7 +223,8 @@ function mergeConversationRecords(existing: ConversationRecord, selected: Conver firstUserMessagePreview: firstNonEmptyString(selected.firstUserMessagePreview, existing.firstUserMessagePreview) ?? null, messageCount: selected.messageCount ?? (selectedMessages.length > 0 ? selectedMessages.length : existing.messageCount ?? existingMessages.length), messages: selectedMessages.length > 0 ? selectedMessages : existing.messages, - updatedAt: latestTimestamp(selected.updatedAt, selected.snapshot?.updatedAt, existing.updatedAt, existing.snapshot?.updatedAt) + lastUserMessageAt, + updatedAt: lastUserMessageAt ?? firstNonEmptyString(selected.startedAt, existing.startedAt, selected.updatedAt, existing.updatedAt, selected.snapshot?.updatedAt, existing.snapshot?.updatedAt) }; } @@ -293,18 +300,22 @@ function firstReadableSentence(...values: unknown[]): string | null { return null; } -function conversationActivityUpdatedAt(conversation: ConversationRecord): string | null { - const messageTimes = (conversation.messages ?? []).flatMap((message) => [ - message.updatedAt, - message.runnerTrace?.updatedAt, - message.createdAt - ]); - return latestTimestamp( +export function conversationDisplayUpdatedAt(conversation: ConversationRecord): string | null { + return firstNonEmptyString( + conversation.lastUserMessageAt, + conversation.snapshot?.lastUserMessageAt, + latestUserMessageAtFromMessages(conversation.messages ?? []), + conversation.startedAt, conversation.updatedAt, - conversation.snapshot?.updatedAt, - ...messageTimes, - conversation.startedAt - ); + conversation.snapshot?.updatedAt + ) ?? null; +} + +export function latestUserMessageAtFromMessages(messages: ChatMessage[] | undefined): string | null { + const userMessageTimes = (messages ?? []) + .filter((message) => message.role === "user") + .flatMap((message) => [message.createdAt, message.updatedAt]); + return latestTimestamp(...userMessageTimes); } function latestTimestamp(...values: unknown[]): string | null { diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 5159e2a0..de4f07c9 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -4,7 +4,7 @@ import { api } from "@/api"; import { mergeRunnerTrace, snapshotToRunnerTrace, subscribeToTrace, type TraceSnapshot } from "@/composables/useTraceSubscription"; import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, ConversationRecord, LiveSurface, ProviderProfile, TraceEvent, WorkspaceRecord } from "@/types"; import { DEFAULT_WORKBENCH_PROJECT_ID, firstNonEmptyString, nextProtocolId, normalizeWorkbenchConversationId, rememberWorkbenchProjectId, resolveInitialWorkbenchProjectId, workspaceProjectId } from "@/utils"; -import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, defaultProviderProfileOptions, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeSelectedConversation, normalizeRecentDrafts, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session"; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000; const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000; @@ -170,12 +170,13 @@ export const useWorkbenchStore = defineStore("workbench", () => { const latestAgent = [...activityMessages].reverse().find((message) => message.role === "agent"); const latestTrace = [...activityMessages].reverse().find((message) => message.traceId); const userMessage = activityMessages.find((message) => message.role === "user"); - const updatedAt = firstNonEmptyString(input.updatedAt, traceMessage?.updatedAt, latestAgent?.updatedAt, latestAgent?.runnerTrace?.updatedAt, latestTrace?.updatedAt, new Date().toISOString()) ?? new Date().toISOString(); const status = firstNonEmptyString(input.status, latestAgent?.status, existing?.status) ?? existing?.status ?? null; const sessionId = firstNonEmptyString(input.sessionId, traceMessage?.sessionId, latestAgent?.sessionId, existing?.sessionId, existing?.session?.sessionId, selectedSessionId.value) ?? null; const threadId = firstNonEmptyString(input.threadId, traceMessage?.threadId, latestAgent?.threadId, existing?.threadId, existing?.session?.threadId, selectedThreadId.value) ?? null; const lastTraceId = firstNonEmptyString(input.traceId, latestTrace?.traceId, existing?.lastTraceId) ?? null; const nextMessages = activityMessages.length > 0 ? activityMessages : existing?.messages ?? []; + const lastUserMessageAt = latestUserMessageAtFromMessages(nextMessages) ?? firstNonEmptyString(existing?.lastUserMessageAt, existing?.snapshot?.lastUserMessageAt) ?? null; + const updatedAt = lastUserMessageAt ?? conversationDisplayUpdatedAt(existing ?? { conversationId }) ?? firstNonEmptyString(input.updatedAt, traceMessage?.updatedAt, latestAgent?.updatedAt, latestAgent?.runnerTrace?.updatedAt, latestTrace?.updatedAt, new Date().toISOString()) ?? new Date().toISOString(); const firstUserMessagePreview = firstNonEmptyString(existing?.firstUserMessagePreview, existing?.snapshot?.firstUserMessagePreview, userMessage?.text) ?? null; const next: ConversationRecord = { ...(existing ?? { conversationId }), @@ -186,12 +187,14 @@ export const useWorkbenchStore = defineStore("workbench", () => { status, lastTraceId, updatedAt, + lastUserMessageAt, messages: nextMessages, messageCount: nextMessages.length || existing?.messageCount, firstUserMessagePreview, snapshot: { ...(existing?.snapshot ?? {}), ...(status ? { sessionStatus: status } : {}), + ...(lastUserMessageAt ? { lastUserMessageAt } : {}), updatedAt } }; diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 236745f7..b2184184 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -606,6 +606,7 @@ } .session-tab { + position: relative; display: flex; align-items: center; min-width: 0; @@ -618,6 +619,12 @@ padding: 7px 9px; color: #334155; text-align: left; + overflow: hidden; +} + +.session-tab > * { + position: relative; + z-index: 1; } .session-tab[data-active="true"] { @@ -625,6 +632,32 @@ background: #f0f9ff; } +.session-tab[data-running="true"]::after { + content: ""; + position: absolute; + right: 8px; + bottom: 3px; + left: 8px; + height: 2px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(8, 145, 178, 0), rgba(8, 145, 178, 0.82), rgba(8, 145, 178, 0)); + background-size: 220% 100%; + opacity: 0.82; + pointer-events: none; + animation: session-tab-running 1100ms linear infinite; +} + +@keyframes session-tab-running { + from { background-position: 220% 0; } + to { background-position: -220% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .session-tab[data-running="true"]::after { + animation: none; + } +} + .session-tab-title { min-width: 0; flex: 1 1 auto; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 4f6a0909..c1d7b494 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -94,6 +94,7 @@ export interface WorkspaceRecord { [key: string]: unknown; }; selectedConversation?: ConversationRecord | null; + createdAt?: string; } export interface ConversationRecord { @@ -103,6 +104,7 @@ export interface ConversationRecord { threadId?: string | null; status?: string | null; updatedAt?: string | null; + lastUserMessageAt?: string | null; startedAt?: string | null; lastTraceId?: string | null; messageCount?: number;