fix: unify session tab status authority (#1336)

This commit is contained in:
Lyon
2026-06-16 17:27:59 +08:00
committed by GitHub
parent 24a0adddd4
commit d7202b808d
6 changed files with 115 additions and 26 deletions
+12 -8
View File
@@ -694,6 +694,17 @@ function firstNonEmptyValue(...values) {
function submitCodeAgentChatTurn({ params, options, traceId }) {
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
const results = options.codeAgentChatResults ?? createCodeAgentChatResultStore();
const acceptedPayload = {
accepted: true,
status: "running",
traceId,
conversationId: safeConversationId(params.conversationId) || null,
sessionId: safeSessionId(params.sessionId) || null,
threadId: safeOpaqueId(params.threadId) || null,
projectId: params.projectId ?? null,
updatedAt: new Date().toISOString()
};
void recordCodeAgentSessionOwner({ payload: acceptedPayload, params, options, status: "running" });
if (codeAgentAgentRunAdapterEnabled(options.env ?? process.env)) {
const initial = withCodeAgentBillingReservation(initialAgentRunChatResult({ params, options, traceId }), params);
results.set(traceId, annotateOwner(initial, params));
@@ -744,14 +755,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) {
setImmediate(() => { run(); });
return;
}
results.set(traceId, {
accepted: true,
status: "running",
traceId,
conversationId: safeConversationId(params.conversationId) || null,
sessionId: safeSessionId(params.sessionId) || null,
updatedAt: new Date().toISOString()
});
results.set(traceId, acceptedPayload);
traceStore.ensure(traceId, {
runnerKind: "codex-app-server-stdio-runner",
workspace: options.workspace ?? options.env?.HWLAB_CODE_AGENT_CODEX_WORKSPACE ?? options.env?.HWLAB_CODE_AGENT_WORKSPACE ?? null,
@@ -146,12 +146,12 @@ test("R2 running session tabs expose animation state without rewriting label", (
agentMessage({ conversationId: "cnv_running", status: "running", updatedAt: "2026-01-04T00:01:00.000Z" })
]
}
], null);
], null, { ses_running: { sessionId: "ses_running", status: "running" } });
assert.equal(tabs[0]?.label, "保持原始标题");
assert.equal(tabs[0]?.running, true);
});
test("R2 selected workspace stubs cannot clear a running conversation tab", () => {
test("R2 session tabs do not infer running state from workspace stubs", () => {
const conversations: ConversationRecord[] = [{
conversationId: "cnv_r2",
sessionId: "ses_r2",
@@ -166,13 +166,16 @@ test("R2 selected workspace stubs cannot clear a running conversation tab", () =
const workspace = workspaceRecord({ activeTraceId: null, sessionStatus: "idle" });
const merged = mergeSelectedConversation(conversations, workspace);
const tabs = sortSessionTabs(merged, "cnv_r2");
assert.equal(tabs[0]?.status, "running");
assert.equal(tabs[0]?.running, true);
assert.equal(tabs[0]?.status, "unknown");
assert.equal(tabs[0]?.running, false);
assert.equal(tabs[0]?.messageCount, 2);
assert.equal(tabs[0]?.label, "保持原始标题");
const authorityTabs = sortSessionTabs(merged, "cnv_r2", { ses_r2: { sessionId: "ses_r2", status: "running" } });
assert.equal(authorityTabs[0]?.status, "running");
assert.equal(authorityTabs[0]?.running, true);
});
test("R2 active in-flight turn updates the selected session tab before list refresh catches up", () => {
test("R2 active in-flight turn updates tab content but not status without session authority", () => {
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" })
@@ -184,13 +187,16 @@ test("R2 active in-flight turn updates the selected session tab before list refr
chatPending: true
});
const tabs = sortSessionTabs(merged, "cnv_live");
assert.equal(tabs[0]?.status, "running");
assert.equal(tabs[0]?.running, true);
assert.equal(tabs[0]?.status, "unknown");
assert.equal(tabs[0]?.running, false);
assert.equal(tabs[0]?.label, "立即显示左侧运行态");
assert.equal(tabs[0]?.messageCount, 2);
const authorityTabs = sortSessionTabs(merged, "cnv_live", { ses_live: { sessionId: "ses_live", status: "running" } });
assert.equal(authorityTabs[0]?.status, "running");
assert.equal(authorityTabs[0]?.running, true);
});
test("R2 session tabs keep restored running message ahead of stale idle conversation status", () => {
test("R2 session tabs keep trace metadata but status comes only from session authority", () => {
const conversation: ConversationRecord = {
conversationId: "cnv_stale_snapshot",
sessionId: "ses_stale_snapshot",
@@ -200,8 +206,12 @@ test("R2 session tabs keep restored running message ahead of stale idle conversa
};
assert.equal(resolveConversationSessionStatus(conversation), "running");
const tabs = sortSessionTabs([conversation], "cnv_stale_snapshot");
assert.equal(tabs[0]?.running, true);
assert.equal(tabs[0]?.status, "unknown");
assert.equal(tabs[0]?.running, false);
assert.equal(tabs[0]?.lastTraceId, "trc_stale");
const authorityTabs = sortSessionTabs([conversation], "cnv_stale_snapshot", { ses_stale_snapshot: { sessionId: "ses_stale_snapshot", status: "idle" } });
assert.equal(authorityTabs[0]?.status, "idle");
assert.equal(authorityTabs[0]?.running, false);
});
test("R2 agent message titles drop transient running wording", () => {
+1
View File
@@ -5,5 +5,6 @@ export const agentAPI = {
sendAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent", activityRef }),
steerAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer", activityRef }),
getAgentChatResult: (resultUrl: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }),
getAgentSession: (sessionId: string): Promise<ApiResult<{ session?: Record<string, unknown> }>> => fetchJson(`/v1/agent/sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: 8000, timeoutName: "Code Agent session" }),
cancelAgentMessage: (payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean; canceled?: boolean; error?: { code?: string; message?: string; userMessage?: string } }>> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" })
};
@@ -87,7 +87,7 @@ function formatSessionUpdatedTime(value: string | null | undefined): string {
<div class="session-list" id="session-tabs" :data-loading="showSessionListLoading">
<LoadingState v-if="showSessionListLoading" class="session-list-loading" label="加载中" />
<template v-else>
<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)">
<button v-for="tab in workbench.sessionTabs" :key="tab.conversationId" class="session-tab" :data-active="tab.active" :data-running="tab.running" :data-status="tab.status || undefined" :data-session-id="tab.sessionId || undefined" :data-conversation-id="tab.conversationId" 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>
@@ -20,6 +20,16 @@ export interface SessionTab extends ConversationRecord {
running: boolean;
}
export interface SessionStatusAuthority {
sessionId: string;
status: string | null;
updatedAt?: string | null;
lastTraceId?: string | null;
loadedAt?: string | null;
}
export type SessionStatusAuthorityMap = Readonly<Record<string, SessionStatusAuthority | undefined>>;
export interface DraftEntry {
text: string;
ts: string;
@@ -143,11 +153,11 @@ export function workspaceWithClearedActiveTrace(workspace: WorkspaceRecord | nul
};
}
export function conversationToSessionTab(conversation: ConversationRecord, activeConversationId: string | null): SessionTab {
export function conversationToSessionTab(conversation: ConversationRecord, activeConversationId: string | null, sessionStatusAuthority: SessionStatusAuthorityMap = {}): SessionTab {
const sessionId = firstNonEmptyString(conversation.sessionId, conversation.session?.sessionId);
const conversationId = conversation.conversationId;
const updatedAt = conversationDisplayUpdatedAt(conversation);
const status = resolveConversationSessionStatus(conversation);
const status = normalizeSessionStatus(sessionId ? sessionStatusAuthority[sessionId]?.status : null) ?? "unknown";
const trace = firstNonEmptyString(latestAgentMessage(conversation.messages)?.traceId, conversation.messages?.at(-1)?.traceId, conversation.lastTraceId);
const userMessage = conversation.messages?.find((message) => message.role === "user");
const running = isActiveStatus(status);
@@ -164,7 +174,7 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ
...conversation,
key: sessionId ?? conversationId,
label: preview ?? `Session ${shortToken(sessionId ?? conversationId, 8)}`,
subtitle: `${status === "running" ? "处理中" : trace ? `trace ${shortToken(trace, 8)}` : "未开始"} · ${conversation.messageCount ?? conversation.messages?.length ?? 0}`,
subtitle: `${running ? "处理中" : trace ? `trace ${shortToken(trace, 8)}` : status === "unknown" ? "状态同步中" : "未开始"} · ${conversation.messageCount ?? conversation.messages?.length ?? 0}`,
sessionId,
threadId: firstNonEmptyString(conversation.threadId, conversation.session?.threadId),
lastTraceId: trace,
@@ -225,8 +235,8 @@ export function normalizeWorkbenchMessageTitle(role: ChatMessage["role"], title:
return text || fallback;
}
export function sortSessionTabs(conversations: ConversationRecord[], activeConversationId: string | null): SessionTab[] {
return conversations.map((conversation) => conversationToSessionTab(conversation, activeConversationId)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt));
export function sortSessionTabs(conversations: ConversationRecord[], activeConversationId: string | null, sessionStatusAuthority: SessionStatusAuthorityMap = {}): SessionTab[] {
return conversations.map((conversation) => conversationToSessionTab(conversation, activeConversationId, sessionStatusAuthority)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt));
}
export function mergeActiveConversationActivity(conversations: ConversationRecord[], input: ActiveConversationActivityInput): ConversationRecord[] {
+67 -3
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, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeActiveConversationActivity, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption } from "./workbench-session";
import { RECENT_DRAFTS_STORAGE_KEY, activeTraceIdFromWorkspace, conversationDisplayUpdatedAt, defaultProviderProfileOptions, latestUserMessageAtFromMessages, mergeSelectedConversation, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectedConversationIdFromWorkspace, shouldApplyWorkspaceSnapshot, shouldShowSessionListLoading, sortSessionTabs, workspaceWithClearedActiveTrace, type DraftEntry, type ProviderProfileOption, type SessionStatusAuthority } from "./workbench-session";
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000;
const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000;
@@ -29,13 +29,14 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const activeAbort = ref<AbortController | null>(null);
const currentRequest = ref<{ traceId: string; conversationId: string | null; sessionId: string | null; threadId: string | null; status?: string | null } | null>(null);
const workspaceSelectionEpoch = ref(0);
const sessionStatusAuthority = ref<Record<string, SessionStatusAuthority>>({});
const visibleConversations = computed(() => mergeSelectedConversation(conversations.value, workspace.value));
const activeConversationId = computed(() => firstNonEmptyString(switchingConversationId.value, workspace.value?.selectedConversationId, workspace.value?.workspace?.selectedConversationId, messages.value.find((message) => message.conversationId)?.conversationId));
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(mergeActiveConversationActivity(visibleConversations.value, { activeConversationId: activeConversationId.value, messages: messages.value, currentRequest: currentRequest.value, chatPending: chatPending.value }), activeConversationId.value));
const sessionTabs = computed(() => sortSessionTabs(visibleConversations.value, activeConversationId.value, sessionStatusAuthority.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 }));
@@ -60,6 +61,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const nextWorkspace = workspaceResult.data?.workspace ?? null;
const nextConversations = conversationsResult.ok ? conversationsResult.data?.conversations ?? [] : [];
conversations.value = nextConversations;
void refreshSessionStatusAuthority(mergeSelectedConversation(nextConversations, nextWorkspace));
if (shouldApplyWorkspaceSnapshot({ requestEpoch, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: nextWorkspace })) {
workspace.value = nextWorkspace;
providerProfile.value = firstNonEmptyString(workspace.value?.providerProfile, workspace.value?.workspace?.providerProfile, providerProfile.value) ?? providerProfile.value;
@@ -95,6 +97,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
workspace.value = response.data?.workspace ?? current;
messages.value = messagesFromWorkspace(workspace.value);
currentRequest.value = null;
await refreshSelectedSessionStatus();
await refreshConversations();
}
}
@@ -107,6 +110,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
switchingConversationId.value = conversation.conversationId;
workspace.value = optimisticWorkspaceSelection(current, conversation, tabProjectId);
messages.value = messagesFromConversation(conversation);
void refreshSessionStatusById(sessionIdFromConversation(conversation));
loading.value = true;
const response = await api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" });
loading.value = false;
@@ -118,6 +122,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
void hydrateTerminalMessageDiagnostics();
reattachRestoredActiveTrace();
currentRequest.value = null;
await refreshSelectedSessionStatus(conversation);
return;
}
if (response.status === 404 && await retrySelectConversationWithFreshWorkspace(conversation, tabProjectId, requestEpoch)) return;
@@ -148,6 +153,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return false;
}
conversations.value = [conversation, ...conversations.value.filter((item) => item.conversationId !== normalized)];
void refreshSessionStatusById(sessionIdFromConversation(conversation));
await selectConversation(conversation);
return activeConversationId.value === normalized;
}
@@ -167,7 +173,48 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function refreshConversations(): Promise<void> {
const response = await api.workbench.conversations(activeProjectId.value);
conversationsReady.value = true;
if (response.ok) conversations.value = response.data?.conversations ?? [];
if (response.ok) {
conversations.value = response.data?.conversations ?? [];
await refreshSessionStatusAuthority(visibleConversations.value);
}
}
async function refreshSessionStatusAuthority(source: ConversationRecord[] = visibleConversations.value): Promise<void> {
await Promise.all(uniqueSessionIds(source).map((sessionId) => refreshSessionStatusById(sessionId)));
}
async function refreshSelectedSessionStatus(fallbackConversation?: ConversationRecord | null): Promise<void> {
await refreshSessionStatusById(firstNonEmptyString(
fallbackConversation ? sessionIdFromConversation(fallbackConversation) : null,
selectedSessionId.value,
workspace.value?.selectedAgentSessionId,
workspace.value?.workspace?.selectedAgentSessionId
));
}
async function refreshSessionStatusById(sessionId: string | null | undefined): Promise<void> {
const id = firstNonEmptyString(sessionId);
if (!id) return;
const response = await api.agent.getAgentSession(id);
const session = response.data?.session && typeof response.data.session === "object" ? response.data.session : null;
if (!response.ok || !session) {
const next = { ...sessionStatusAuthority.value };
delete next[id];
sessionStatusAuthority.value = next;
return;
}
const returnedSessionId = firstNonEmptyString(session.sessionId, id);
if (returnedSessionId !== id) return;
sessionStatusAuthority.value = {
...sessionStatusAuthority.value,
[id]: {
sessionId: id,
status: firstNonEmptyString(session.status) ?? null,
updatedAt: firstNonEmptyString(session.updatedAt) ?? null,
lastTraceId: firstNonEmptyString(session.lastTraceId) ?? null,
loadedAt: new Date().toISOString()
}
};
}
function bumpConversationActivity(input: { conversationId?: string | null; sessionId?: string | null; threadId?: string | null; traceId?: string | null; status?: string | null; updatedAt?: string | null }): void {
@@ -228,6 +275,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
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" });
void refreshSessionStatusById(sessionId);
chatPending.value = true;
currentRequest.value = { traceId, conversationId, sessionId, threadId, status: "running" };
rememberRecentDraft(value);
@@ -240,8 +288,10 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (steerMode && response.status === 404) void clearActiveTrace(traceId, "steer-trace-not-found");
chatPending.value = false;
currentRequest.value = null;
void refreshSessionStatusById(sessionId);
return;
}
void refreshSessionStatusById(sessionId);
subscribe(traceId, response.data);
}
@@ -254,6 +304,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
void clearActiveTrace(traceId, "cancel-agent-message", canceledStatus);
if (message.status === "running") chatPending.value = false;
currentRequest.value = null;
void refreshSessionStatusById(message.sessionId ?? selectedSessionId.value);
}
async function cancelRunningTrace(): Promise<void> {
@@ -373,6 +424,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
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) });
void refreshSessionStatusById(trace.sessionId ?? selectedSessionId.value);
}
function completeTrace(traceId: string, result: AgentChatResultResponse): void {
@@ -389,6 +441,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-terminal", workspaceSessionStatusFromChatStatus(terminalStatus));
void refreshSessionStatusById(result.sessionId ?? selectedSessionId.value);
}
async function hydrateTerminalMessageDiagnostics(): Promise<void> {
@@ -412,6 +465,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return { ...message, status, title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
});
bumpConversationActivity({ conversationId: firstNonEmptyString((result as Record<string, unknown>).conversationId), sessionId: result.sessionId, threadId: result.threadId, traceId, status: statusFromResult(result.status), updatedAt: resultActivityUpdatedAt(result) });
void refreshSessionStatusById(result.sessionId ?? selectedSessionId.value);
}
function failTrace(traceId: string, message: string): void {
@@ -419,6 +473,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
chatPending.value = false;
currentRequest.value = null;
void clearActiveTrace(traceId, "trace-infrastructure-error", "failed");
void refreshSelectedSessionStatus();
}
function markMessage(traceId: string, patch: Partial<ChatMessage>): void {
@@ -484,6 +539,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
void hydrateTerminalMessageDiagnostics();
reattachRestoredActiveTrace();
currentRequest.value = null;
await refreshSelectedSessionStatus(conversation);
return true;
}
@@ -495,6 +551,14 @@ export const useWorkbenchStore = defineStore("workbench", () => {
return { projectId, workspace, conversations, messages, providerProfile, providerOptions, recentDrafts, codeAgentTimeoutMs, gatewayShellTimeoutMs, live, loading, sessionListLoading, chatPending, error, activeConversationId, selectedSessionId, selectedThreadId, sessionTabs, composer, hydrate, refreshLive, createSession, selectConversation, selectConversationById, deleteCurrentSession, refreshConversations, submitMessage, cancelAgentMessage, cancelRunningTrace, retryAgentMessage, replayAgentTrace, setProviderProfile, refreshProviderOptions, pickDraft, clearRecentDrafts, clearConversation, recordActivity };
});
function sessionIdFromConversation(conversation: ConversationRecord | null | undefined): string | null {
return firstNonEmptyString(conversation?.sessionId, conversation?.session?.sessionId) ?? null;
}
function uniqueSessionIds(conversations: ConversationRecord[]): string[] {
return [...new Set(conversations.map((conversation) => sessionIdFromConversation(conversation)).filter((sessionId): sessionId is string => Boolean(sessionId)))];
}
function optimisticWorkspaceSelection(current: WorkspaceRecord, conversation: ConversationRecord, projectId: string): WorkspaceRecord {
return {
...current,