fix: reflect live workbench turns in session tabs (#1319)
This commit is contained in:
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessage, ConversationRecord, WorkspaceRecord } from "../src/types/index.ts";
|
||||
import { defaultProviderProfileOptions, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, resolveConversationSessionStatus, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts";
|
||||
import { defaultProviderProfileOptions, mergeActiveConversationActivity, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, resolveConversationSessionStatus, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace } from "../src/stores/workbench-session.ts";
|
||||
|
||||
test("R2 composer ignores stale workspace activeTraceId without verified active message", () => {
|
||||
const workspace = workspaceRecord({ activeTraceId: "trc_stale", sessionStatus: "running" });
|
||||
@@ -130,6 +130,24 @@ test("R2 selected workspace stubs cannot clear a running conversation tab", () =
|
||||
assert.equal(tabs[0]?.label, "保持原始标题");
|
||||
});
|
||||
|
||||
test("R2 active in-flight turn updates the selected session tab before list refresh catches up", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
userMessage({ conversationId: "cnv_live", sessionId: "ses_live", text: "立即显示左侧运行态", createdAt: "2026-01-04T00:00:00.000Z" }),
|
||||
agentMessage({ conversationId: "cnv_live", sessionId: "ses_live", status: "running", traceId: "trc_live", updatedAt: "2026-01-04T00:00:01.000Z" })
|
||||
];
|
||||
const merged = mergeActiveConversationActivity([{ conversationId: "cnv_live", sessionId: "ses_live", status: "active", messageCount: 0 }], {
|
||||
activeConversationId: "cnv_live",
|
||||
messages,
|
||||
currentRequest: { conversationId: "cnv_live", sessionId: "ses_live", traceId: "trc_live", status: "running" },
|
||||
chatPending: true
|
||||
});
|
||||
const tabs = sortSessionTabs(merged, "cnv_live");
|
||||
assert.equal(tabs[0]?.status, "running");
|
||||
assert.equal(tabs[0]?.running, true);
|
||||
assert.equal(tabs[0]?.label, "立即显示左侧运行态");
|
||||
assert.equal(tabs[0]?.messageCount, 2);
|
||||
});
|
||||
|
||||
test("R2 session tabs use conversation status before stale snapshot or message status", () => {
|
||||
const conversation: ConversationRecord = {
|
||||
conversationId: "cnv_stale_snapshot",
|
||||
|
||||
@@ -31,6 +31,13 @@ export interface ProviderProfileOption {
|
||||
configured?: boolean;
|
||||
}
|
||||
|
||||
export interface ActiveConversationActivityInput {
|
||||
activeConversationId: string | null;
|
||||
messages: ChatMessage[];
|
||||
currentRequest?: { traceId?: string | null; conversationId?: string | null; sessionId?: string | null; threadId?: string | null; status?: string | null } | null;
|
||||
chatPending: boolean;
|
||||
}
|
||||
|
||||
export const RECENT_DRAFTS_STORAGE_KEY = "hwlab.workbench.recentDrafts.v1";
|
||||
export const RECENT_DRAFTS_LIMIT = 8;
|
||||
const OBJECT_OBJECT_TEXT = "[object Object]";
|
||||
@@ -173,6 +180,44 @@ export function sortSessionTabs(conversations: ConversationRecord[], activeConve
|
||||
return conversations.map((conversation) => conversationToSessionTab(conversation, activeConversationId)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt));
|
||||
}
|
||||
|
||||
export function mergeActiveConversationActivity(conversations: ConversationRecord[], input: ActiveConversationActivityInput): ConversationRecord[] {
|
||||
const conversationId = firstNonEmptyString(input.activeConversationId, input.currentRequest?.conversationId, input.messages.find((message) => message.conversationId)?.conversationId);
|
||||
if (!conversationId) return conversations;
|
||||
const activityMessages = input.messages.filter((message) => !message.conversationId || message.conversationId === conversationId);
|
||||
const latestAgent = latestAgentMessage(activityMessages);
|
||||
const requestMatches = !input.currentRequest?.conversationId || input.currentRequest.conversationId === conversationId;
|
||||
const liveStatus = requestMatches && isActiveStatus(input.currentRequest?.status)
|
||||
? input.currentRequest?.status
|
||||
: input.chatPending && isActiveStatus(latestAgent?.status)
|
||||
? latestAgent?.status
|
||||
: null;
|
||||
if (!liveStatus || activityMessages.length === 0) return conversations;
|
||||
const existing = conversations.find((conversation) => conversation.conversationId === conversationId) ?? { conversationId };
|
||||
const userMessage = activityMessages.find((message) => message.role === "user");
|
||||
const lastUserMessageAt = latestUserMessageAtFromMessages(activityMessages) ?? firstNonEmptyString(existing.lastUserMessageAt, existing.snapshot?.lastUserMessageAt) ?? null;
|
||||
const updatedAt = lastUserMessageAt ?? conversationDisplayUpdatedAt(existing) ?? new Date().toISOString();
|
||||
const next: ConversationRecord = {
|
||||
...existing,
|
||||
conversationId,
|
||||
sessionId: firstNonEmptyString(input.currentRequest?.sessionId, latestAgent?.sessionId, existing.sessionId, existing.session?.sessionId) ?? null,
|
||||
threadId: firstNonEmptyString(input.currentRequest?.threadId, latestAgent?.threadId, existing.threadId, existing.session?.threadId) ?? null,
|
||||
status: liveStatus,
|
||||
lastTraceId: firstNonEmptyString(input.currentRequest?.traceId, latestAgent?.traceId, latestAgent?.runnerTrace?.traceId, existing.lastTraceId) ?? null,
|
||||
firstUserMessagePreview: firstNonEmptyString(existing.firstUserMessagePreview, existing.snapshot?.firstUserMessagePreview, userMessage?.text, userMessage?.content) ?? null,
|
||||
messages: activityMessages,
|
||||
messageCount: activityMessages.length,
|
||||
lastUserMessageAt,
|
||||
updatedAt,
|
||||
snapshot: {
|
||||
...(existing.snapshot ?? {}),
|
||||
sessionStatus: liveStatus,
|
||||
...(lastUserMessageAt ? { lastUserMessageAt } : {}),
|
||||
updatedAt
|
||||
}
|
||||
};
|
||||
return [next, ...conversations.filter((conversation) => conversation.conversationId !== conversationId)];
|
||||
}
|
||||
|
||||
export function recordRecentDraft(existing: DraftEntry[], text: string, now = new Date().toISOString()): DraftEntry[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return existing;
|
||||
|
||||
@@ -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, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session";
|
||||
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeActiveConversationActivity, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveComposerState, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, 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;
|
||||
@@ -35,7 +35,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
|
||||
const selectedSessionId = computed(() => firstNonEmptyString(workspace.value?.selectedAgentSessionId, workspace.value?.workspace?.selectedAgentSessionId, activeConversation.value?.sessionId));
|
||||
const selectedThreadId = computed(() => firstNonEmptyString(workspace.value?.workspace?.threadId, activeConversation.value?.threadId));
|
||||
const activeConversation = computed(() => visibleConversations.value.find((item) => item.conversationId === activeConversationId.value) ?? null);
|
||||
const sessionTabs = computed(() => sortSessionTabs(visibleConversations.value, activeConversationId.value));
|
||||
const sessionTabs = computed(() => sortSessionTabs(mergeActiveConversationActivity(visibleConversations.value, { activeConversationId: activeConversationId.value, messages: messages.value, currentRequest: currentRequest.value, chatPending: chatPending.value }), activeConversationId.value));
|
||||
const sessionListLoading = computed(() => shouldShowSessionListLoading({ loading: loading.value, conversationsReady: conversationsReady.value }));
|
||||
const activeProjectId = computed(() => workspaceProjectId(workspace.value, projectId.value));
|
||||
const composer = computed(() => resolveComposerState({ workspace: workspace.value, messages: messages.value, conversations: visibleConversations.value, activeConversationId: activeConversationId.value, chatPending: chatPending.value, currentRequest: currentRequest.value }));
|
||||
|
||||
Reference in New Issue
Block a user