Files
clawX/src/stores/chat/session-actions.ts

329 lines
14 KiB
TypeScript

import { invokeIpc } from '@/lib/api-client';
import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
const LABEL_FETCH_CONCURRENCY = 5;
function getAgentIdFromSessionKey(sessionKey: string): string {
if (!sessionKey.startsWith('agent:')) return 'main';
const [, agentId] = sessionKey.split(':');
return agentId || 'main';
}
function parseSessionUpdatedAtMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return toMs(value);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
export function createSessionActions(
set: ChatSet,
get: ChatGet,
): Pick<SessionHistoryActions, 'loadSessions' | 'switchSession' | 'newSession' | 'deleteSession' | 'cleanupEmptySession'> {
return {
loadSessions: async () => {
try {
const result = await invokeIpc(
'gateway:rpc',
'sessions.list',
{}
) as { success: boolean; result?: Record<string, unknown>; error?: string };
if (result.success && result.result) {
const data = result.result;
const rawSessions = Array.isArray(data.sessions) ? data.sessions : [];
const sessions: ChatSession[] = rawSessions.map((s: Record<string, unknown>) => ({
key: String(s.key || ''),
label: s.label ? String(s.label) : undefined,
displayName: s.displayName ? String(s.displayName) : undefined,
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
model: s.model ? String(s.model) : undefined,
updatedAt: parseSessionUpdatedAtMs(s.updatedAt),
})).filter((s: ChatSession) => s.key);
const canonicalBySuffix = new Map<string, string>();
for (const session of sessions) {
if (!session.key.startsWith('agent:')) continue;
const parts = session.key.split(':');
if (parts.length < 3) continue;
const suffix = parts.slice(2).join(':');
if (suffix && !canonicalBySuffix.has(suffix)) {
canonicalBySuffix.set(suffix, session.key);
}
}
// Deduplicate: if both short and canonical existed, keep canonical only
const seen = new Set<string>();
const dedupedSessions = sessions.filter((s) => {
if (!s.key.startsWith('agent:') && canonicalBySuffix.has(s.key)) return false;
if (seen.has(s.key)) return false;
seen.add(s.key);
return true;
});
const { currentSessionKey } = get();
let nextSessionKey = currentSessionKey || DEFAULT_SESSION_KEY;
if (!nextSessionKey.startsWith('agent:')) {
const canonicalMatch = canonicalBySuffix.get(nextSessionKey);
if (canonicalMatch) {
nextSessionKey = canonicalMatch;
}
}
if (!dedupedSessions.find((s) => s.key === nextSessionKey) && dedupedSessions.length > 0) {
// Current session not found in the backend list
const isNewEmptySession = get().messages.length === 0;
if (!isNewEmptySession) {
nextSessionKey = dedupedSessions[0].key;
}
}
const sessionsWithCurrent = !dedupedSessions.find((s) => s.key === nextSessionKey) && nextSessionKey
? [
...dedupedSessions,
{ key: nextSessionKey, displayName: nextSessionKey },
]
: dedupedSessions;
const discoveredActivity = Object.fromEntries(
sessionsWithCurrent
.filter((session) => typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt))
.map((session) => [session.key, session.updatedAt!]),
);
set((state) => ({
sessions: sessionsWithCurrent,
currentSessionKey: nextSessionKey,
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
sessionLastActivity: {
...state.sessionLastActivity,
...discoveredActivity,
},
}));
if (currentSessionKey !== nextSessionKey) {
get().loadHistory();
}
// Background: fetch first user message for every non-main session to populate labels.
// Concurrency-limited to avoid flooding the gateway with parallel RPCs.
// By the time this runs, the gateway should already be fully ready (Sidebar
// gates on gatewayReady), so no startup-retry loop is needed.
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
if (sessionsToLabel.length > 0) {
void (async () => {
for (let i = 0; i < sessionsToLabel.length; i += LABEL_FETCH_CONCURRENCY) {
const batch = sessionsToLabel.slice(i, i + LABEL_FETCH_CONCURRENCY);
await Promise.all(
batch.map(async (session) => {
try {
const r = await invokeIpc(
'gateway:rpc',
'chat.history',
{ sessionKey: session.key, limit: 1000 },
) as { success: boolean; result?: Record<string, unknown>; error?: string };
if (!r.success || !r.result) return;
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
const firstUser = msgs.find((m) => m.role === 'user');
const lastMsg = msgs[msgs.length - 1];
set((s) => {
const next: Partial<typeof s> = {};
if (firstUser) {
const labelText = getMessageText(firstUser.content).trim();
if (labelText) {
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}` : labelText;
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
}
}
if (lastMsg?.timestamp) {
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
}
return next;
});
} catch { /* ignore per-session errors */ }
}),
);
}
})();
}
}
} catch (err) {
console.warn('Failed to load sessions:', err);
}
},
// ── Switch session ──
switchSession: (key: string) => {
const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get();
// Only treat sessions with no history records and no activity timestamp as empty.
// Relying solely on messages.length is unreliable because switchSession clears
// the current messages before loadHistory runs, creating a race condition that
// could cause sessions with real history to be incorrectly removed from the sidebar.
const leavingEmpty = !currentSessionKey.endsWith(':main')
&& messages.length === 0
&& !sessionLastActivity[currentSessionKey]
&& !sessionLabels[currentSessionKey];
set((s) => ({
currentSessionKey: key,
currentAgentId: getAgentIdFromSessionKey(key),
messages: [],
streamingText: '',
streamingMessage: null,
streamingTools: [],
activeRunId: null,
error: null,
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
...(leavingEmpty ? {
sessions: s.sessions.filter((s) => s.key !== currentSessionKey),
sessionLabels: Object.fromEntries(
Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey),
),
sessionLastActivity: Object.fromEntries(
Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey),
),
} : {}),
}));
get().loadHistory();
},
// ── Delete session ──
//
// NOTE: The OpenClaw Gateway does NOT expose a sessions.delete (or equivalent)
// RPC — confirmed by inspecting client.ts, protocol.ts and the full codebase.
// Deletion is therefore a local-only UI operation: the session is removed from
// the sidebar list and its labels/activity maps are cleared. The underlying
// JSONL history file on disk is intentionally left intact, consistent with the
// newSession() design that avoids sessions.reset to preserve history.
deleteSession: async (key: string) => {
// Soft-delete the session's JSONL transcript on disk.
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
// sessions.list skips it automatically.
try {
const result = await invokeIpc('session:delete', key) as {
success: boolean;
error?: string;
};
if (!result.success) {
console.warn(`[deleteSession] IPC reported failure for ${key}:`, result.error);
}
} catch (err) {
console.warn(`[deleteSession] IPC call failed for ${key}:`, err);
}
const { currentSessionKey, sessions } = get();
const remaining = sessions.filter((s) => s.key !== key);
if (currentSessionKey === key) {
// Switched away from deleted session — pick the first remaining or create new
const next = remaining[0];
set((s) => ({
sessions: remaining,
sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)),
sessionLastActivity: Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== key)),
messages: [],
streamingText: '',
streamingMessage: null,
streamingTools: [],
activeRunId: null,
error: null,
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY,
currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY),
}));
if (next) {
get().loadHistory();
}
} else {
set((s) => ({
sessions: remaining,
sessionLabels: Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== key)),
sessionLastActivity: Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== key)),
}));
}
},
// ── New session ──
newSession: () => {
// Generate a new unique session key and switch to it.
// NOTE: We intentionally do NOT call sessions.reset on the old session.
// sessions.reset archives (renames) the session JSONL file, making old
// conversation history inaccessible when the user switches back to it.
const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get();
// Only treat sessions with no history records and no activity timestamp as empty
const leavingEmpty = !currentSessionKey.endsWith(':main')
&& messages.length === 0
&& !sessionLastActivity[currentSessionKey]
&& !sessionLabels[currentSessionKey];
const prefix = getCanonicalPrefixFromSessions(get().sessions) ?? DEFAULT_CANONICAL_PREFIX;
const newKey = `${prefix}:session-${Date.now()}`;
const newSessionEntry: ChatSession = { key: newKey, displayName: newKey };
set((s) => ({
currentSessionKey: newKey,
currentAgentId: getAgentIdFromSessionKey(newKey),
sessions: [
...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions),
newSessionEntry,
],
sessionLabels: leavingEmpty
? Object.fromEntries(Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey))
: s.sessionLabels,
sessionLastActivity: leavingEmpty
? Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey))
: s.sessionLastActivity,
messages: [],
streamingText: '',
streamingMessage: null,
streamingTools: [],
activeRunId: null,
error: null,
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
}));
},
// ── Cleanup empty session on navigate away ──
cleanupEmptySession: () => {
const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get();
// Only remove non-main sessions that were never used (no messages sent).
// This mirrors the "leavingEmpty" logic in switchSession so that creating
// a new session and immediately navigating away doesn't leave a ghost entry
// in the sidebar.
// Also check sessionLastActivity and sessionLabels comprehensively to prevent
// falsely treating sessions with history as empty due to switchSession clearing messages early.
const isEmptyNonMain = !currentSessionKey.endsWith(':main')
&& messages.length === 0
&& !sessionLastActivity[currentSessionKey]
&& !sessionLabels[currentSessionKey];
if (!isEmptyNonMain) return;
set((s) => ({
sessions: s.sessions.filter((sess) => sess.key !== currentSessionKey),
sessionLabels: Object.fromEntries(
Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey),
),
sessionLastActivity: Object.fromEntries(
Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey),
),
}));
},
// ── Load chat history ──
};
}