Merge pull request #1311 from pikasTech/fix/issue-1310-session-display
修复 Workbench session 时间和运行动效
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user