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 f8d705b0..6d7889a9 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 @@ -46,6 +46,22 @@ 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", () => { + const tabs = sortSessionTabs([ + { + conversationId: "cnv_old_created_recent_message", + sessionId: "ses_old_created_recent_message", + firstUserMessagePreview: "三天前创建但刚刚更新", + 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" })] + }, + { conversationId: "cnv_newer_created", sessionId: "ses_newer_created", firstUserMessagePreview: "创建时间较新但未更新", updatedAt: "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"); +}); + test("R2 session tab labels normalize object previews and reject object placeholders", () => { const tabs = sortSessionTabs([ { conversationId: "cnv_object", sessionId: "ses_object", firstUserMessagePreview: { text: "对象预览标题" } as unknown as string, updatedAt: "2026-01-03T00:00:00.000Z" }, diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 3620e025..fa2d451c 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -90,7 +90,7 @@ 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 = firstNonEmptyString(conversation.updatedAt, conversation.snapshot?.updatedAt, conversation.startedAt); + const updatedAt = conversationActivityUpdatedAt(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"); @@ -223,6 +223,32 @@ 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( + conversation.updatedAt, + conversation.snapshot?.updatedAt, + ...messageTimes, + conversation.startedAt + ); +} + +function latestTimestamp(...values: unknown[]): string | null { + let latest: { value: string; ms: number } | null = null; + for (const value of values) { + const text = firstNonEmptyString(value); + if (!text) continue; + const ms = timestampMs(text); + if (!ms) continue; + if (!latest || ms > latest.ms) latest = { value: text, ms }; + } + return latest?.value ?? firstNonEmptyString(...values) ?? null; +} + function readableSentence(value: unknown): string | null { if (typeof value === "string") return firstSentenceFromText(value); if (typeof value === "number" || typeof value === "boolean") return String(value); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index d8865bf1..34b4245b 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -130,6 +130,43 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (response.ok) conversations.value = response.data?.conversations ?? []; } + function bumpConversationActivity(input: { conversationId?: string | null; sessionId?: string | null; threadId?: string | null; traceId?: string | null; status?: string | null; updatedAt?: string | null }): void { + const traceMessage = input.traceId ? messages.value.find((message) => message.traceId === input.traceId) : null; + const conversationId = firstNonEmptyString(input.conversationId, traceMessage?.conversationId, activeConversationId.value); + if (!conversationId) return; + const existing = conversations.value.find((conversation) => conversation.conversationId === conversationId) ?? null; + const activityMessages = activeConversationId.value === conversationId ? messages.value : messages.value.filter((message) => message.conversationId === conversationId); + 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 firstUserMessagePreview = firstNonEmptyString(existing?.firstUserMessagePreview, existing?.snapshot?.firstUserMessagePreview, userMessage?.text) ?? null; + const next: ConversationRecord = { + ...(existing ?? { conversationId }), + conversationId, + projectId: existing?.projectId ?? activeProjectId.value, + sessionId, + threadId, + status, + lastTraceId, + updatedAt, + messages: nextMessages, + messageCount: nextMessages.length || existing?.messageCount, + firstUserMessagePreview, + snapshot: { + ...(existing?.snapshot ?? {}), + ...(status ? { sessionStatus: status } : {}), + updatedAt + } + }; + conversations.value = [next, ...conversations.value.filter((conversation) => conversation.conversationId !== conversationId)]; + } + async function submitMessage(text: string): Promise { const value = text.trim(); if (!value) return; @@ -146,6 +183,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, conversationId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" }); const pending = makeMessage("agent", "", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent", retryInput: value }); messages.value.push(user, pending); + bumpConversationActivity({ conversationId, sessionId, threadId, traceId, status: "running" }); chatPending.value = true; currentRequest.value = { traceId, conversationId, sessionId, threadId, status: "running" }; rememberRecentDraft(value); @@ -284,6 +322,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { function applyTraceSnapshot(traceId: string, snapshot: TraceSnapshot): void { const trace = snapshotToRunnerTrace(snapshot); messages.value = messages.value.map((message) => message.traceId === traceId ? { ...message, runnerTrace: mergeRunnerTrace(message.runnerTrace, trace), updatedAt: new Date().toISOString() } : message); + bumpConversationActivity({ traceId, sessionId: trace.sessionId, threadId: trace.threadId, status: firstNonEmptyString(trace.status), updatedAt: firstNonEmptyString(trace.updatedAt) }); } function completeTrace(traceId: string, result: AgentChatResultResponse): void { @@ -295,6 +334,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); return { ...message, status: result.status === "completed" ? "completed" : statusFromResult(result.status), text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); + bumpConversationActivity({ conversationId: firstNonEmptyString((result as Record).conversationId), sessionId: result.sessionId, threadId: result.threadId, traceId, status: statusFromResult(result.status), updatedAt: resultActivityUpdatedAt(result) }); chatPending.value = false; currentRequest.value = null; void clearActiveTrace(traceId, "trace-terminal"); @@ -319,6 +359,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); return { ...message, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); + bumpConversationActivity({ conversationId: firstNonEmptyString((result as Record).conversationId), sessionId: result.sessionId, threadId: result.threadId, traceId, status: statusFromResult(result.status), updatedAt: resultActivityUpdatedAt(result) }); } function failTrace(traceId: string, message: string): void { @@ -330,6 +371,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { function markMessage(traceId: string, patch: Partial): void { messages.value = messages.value.map((message) => message.traceId === traceId && message.role === "agent" ? { ...message, ...patch, updatedAt: new Date().toISOString() } : message); + const updated = messages.value.find((message) => message.traceId === traceId && message.role === "agent"); + bumpConversationActivity({ conversationId: updated?.conversationId, sessionId: updated?.sessionId, threadId: updated?.threadId, traceId, status: patch.status ?? updated?.status, updatedAt: updated?.updatedAt }); } async function ensureWorkspace(): Promise { @@ -483,6 +526,12 @@ function recordValue(value: unknown): Record | null { return value && typeof value === "object" ? value as Record : null; } +function resultActivityUpdatedAt(result: AgentChatResultResponse): string | null { + const finalResponse = recordValue(result.finalResponse); + const reply = recordValue(result.reply); + return firstNonEmptyString(result.updatedAt, finalResponse?.updatedAt, reply?.updatedAt, finalResponse?.createdAt, reply?.createdAt); +} + function firstArray(...values: unknown[]): TraceEvent[] { for (const value of values) { if (Array.isArray(value)) return value as TraceEvent[];