feat(chat): enhance sidebar with session management and deletion (#274)
This commit is contained in:
committed by
GitHub
Unverified
parent
f18c91fd6a
commit
c49c7f18bd
@@ -85,6 +85,10 @@ interface ChatState {
|
||||
// Sessions
|
||||
sessions: ChatSession[];
|
||||
currentSessionKey: string;
|
||||
/** First user message text per session key, used as display label */
|
||||
sessionLabels: Record<string, string>;
|
||||
/** Last message timestamp (ms) per session key, used for sorting */
|
||||
sessionLastActivity: Record<string, number>;
|
||||
|
||||
// Thinking
|
||||
showThinking: boolean;
|
||||
@@ -94,6 +98,7 @@ interface ChatState {
|
||||
loadSessions: () => Promise<void>;
|
||||
switchSession: (key: string) => void;
|
||||
newSession: () => void;
|
||||
deleteSession: (key: string) => Promise<void>;
|
||||
loadHistory: (quiet?: boolean) => Promise<void>;
|
||||
sendMessage: (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => Promise<void>;
|
||||
abortRun: () => Promise<void>;
|
||||
@@ -912,6 +917,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
sessions: [],
|
||||
currentSessionKey: DEFAULT_SESSION_KEY,
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
|
||||
showThinking: true,
|
||||
thinkingLevel: null,
|
||||
@@ -982,6 +989,41 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (currentSessionKey !== nextSessionKey) {
|
||||
get().loadHistory();
|
||||
}
|
||||
|
||||
// Background: fetch first user message for every non-main session to populate labels upfront.
|
||||
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything.
|
||||
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
|
||||
if (sessionsToLabel.length > 0) {
|
||||
void Promise.all(
|
||||
sessionsToLabel.map(async (session) => {
|
||||
try {
|
||||
const r = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'chat.history',
|
||||
{ sessionKey: session.key, limit: 200 },
|
||||
) as { success: boolean; result?: Record<string, unknown> };
|
||||
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);
|
||||
@@ -991,7 +1033,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// ── Switch session ──
|
||||
|
||||
switchSession: (key: string) => {
|
||||
set({
|
||||
const { currentSessionKey, messages } = get();
|
||||
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
|
||||
set((s) => ({
|
||||
currentSessionKey: key,
|
||||
messages: [],
|
||||
streamingText: '',
|
||||
@@ -1002,11 +1046,77 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
pendingToolImages: [],
|
||||
});
|
||||
// Load history for new session
|
||||
...(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 and token-usage queries both skip it automatically.
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('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,
|
||||
}));
|
||||
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: () => {
|
||||
@@ -1014,12 +1124,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// 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 } = get();
|
||||
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
|
||||
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,
|
||||
sessions: [...s.sessions, newSessionEntry],
|
||||
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,
|
||||
@@ -1079,6 +1200,32 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
set({ messages: finalMessages, thinkingLevel, loading: false });
|
||||
|
||||
// Extract first user message text as a session label for display in the toolbar.
|
||||
// Skip main sessions (key ends with ":main") — they rely on the Gateway-provided
|
||||
// displayName (e.g. the configured agent name "ClawX") instead.
|
||||
const isMainSession = currentSessionKey.endsWith(':main');
|
||||
if (!isMainSession) {
|
||||
const firstUserMsg = finalMessages.find((m) => m.role === 'user');
|
||||
if (firstUserMsg) {
|
||||
const labelText = getMessageText(firstUserMsg.content).trim();
|
||||
if (labelText) {
|
||||
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
|
||||
set((s) => ({
|
||||
sessionLabels: { ...s.sessionLabels, [currentSessionKey]: truncated },
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record last activity time from the last message in history
|
||||
const lastMsg = finalMessages[finalMessages.length - 1];
|
||||
if (lastMsg?.timestamp) {
|
||||
const lastAt = toMs(lastMsg.timestamp);
|
||||
set((s) => ({
|
||||
sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: lastAt },
|
||||
}));
|
||||
}
|
||||
|
||||
// Async: load missing image previews from disk (updates in background)
|
||||
loadMissingPreviews(finalMessages).then((updated) => {
|
||||
if (updated) {
|
||||
@@ -1170,6 +1317,17 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
lastUserMessageAt: nowMs,
|
||||
}));
|
||||
|
||||
// Update session label with first user message text as soon as it's sent
|
||||
const { sessionLabels, messages } = get();
|
||||
const isFirstMessage = !messages.slice(0, -1).some((m) => m.role === 'user');
|
||||
if (!currentSessionKey.endsWith(':main') && isFirstMessage && !sessionLabels[currentSessionKey] && trimmed) {
|
||||
const truncated = trimmed.length > 50 ? `${trimmed.slice(0, 50)}…` : trimmed;
|
||||
set((s) => ({ sessionLabels: { ...s.sessionLabels, [currentSessionKey]: truncated } }));
|
||||
}
|
||||
|
||||
// Mark this session as most recently active
|
||||
set((s) => ({ sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: nowMs } }));
|
||||
|
||||
// Start the history poll and safety timeout IMMEDIATELY (before the
|
||||
// RPC await) because the gateway's chat.send RPC may block until the
|
||||
// entire agentic conversation finishes — the poll must run in parallel.
|
||||
|
||||
Reference in New Issue
Block a user