Merge pull request #1311 from pikasTech/fix/issue-1310-session-display

修复 Workbench session 时间和运行动效
This commit is contained in:
Lyon
2026-06-16 07:03:55 +08:00
committed by GitHub
7 changed files with 133 additions and 27 deletions
+29 -2
View File
@@ -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
@@ -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>): 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>): ChatMessage {
return { id: `msg_user_${patch.conversationId ?? "r2"}`, role: "user", title: "用户", text: "prompt", status: "sent", createdAt: "2026-01-01T00:00:00.000Z", ...patch };
}
@@ -83,7 +83,7 @@ function formatSessionUpdatedTime(value: string | null | undefined): string {
</div>
<div class="session-model-channel" id="session-model-channel">模型通道{{ workbench.providerProfile }}</div>
<div class="session-list" id="session-tabs">
<button v-for="tab in workbench.sessionTabs" :key="tab.conversationId" class="session-tab" :data-active="tab.active" type="button" :title="tab.label || tab.conversationId" @click="workbench.selectConversation(tab)">
<button v-for="tab in workbench.sessionTabs" :key="tab.conversationId" class="session-tab" :data-active="tab.active" :data-running="tab.running" type="button" :title="tab.label || tab.conversationId" @click="workbench.selectConversation(tab)">
<span class="session-tab-title">{{ tab.label || tab.conversationId }}</span>
<time class="session-tab-time" :datetime="tab.updatedAt || tab.startedAt || undefined">{{ formatSessionUpdatedTime(tab.updatedAt || tab.startedAt) }}</time>
</button>
@@ -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 {
+5 -2
View File
@@ -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
}
};
@@ -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;
+2
View File
@@ -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;