From c49c7f18bd14a61f771e0590b23a49cdbaee7cc8 Mon Sep 17 00:00:00 2001 From: DigHuang <114602213+DigHuang@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:07:42 +0800 Subject: [PATCH] feat(chat): enhance sidebar with session management and deletion (#274) --- electron/main/ipc-handlers.ts | 143 +++++++++++++++++++++++++ electron/preload/index.ts | 2 + src/components/layout/Sidebar.tsx | 89 +++++++++++++++- src/i18n/locales/en/chat.json | 1 - src/i18n/locales/en/common.json | 1 + src/i18n/locales/ja/chat.json | 1 - src/i18n/locales/ja/common.json | 1 + src/i18n/locales/zh/chat.json | 1 - src/i18n/locales/zh/common.json | 1 + src/pages/Chat/ChatToolbar.tsx | 53 +--------- src/stores/chat.ts | 166 +++++++++++++++++++++++++++++- 11 files changed, 396 insertions(+), 63 deletions(-) diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 9de83b2c8..aa2ee9810 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -149,6 +149,9 @@ export function registerIpcHandlers( // Dialog handlers registerDialogHandlers(); + // Session handlers + registerSessionHandlers(); + // App handlers registerAppHandlers(); @@ -2112,3 +2115,143 @@ function registerFileHandlers(): void { return results; }); } + +/** + * Session IPC handlers + * + * Performs a soft-delete of a session's JSONL transcript on disk. + * sessionKey format: "agent::" — e.g. "agent:main:session-1234567890". + * The JSONL file lives at: ~/.openclaw/agents//sessions/.jsonl + * Renaming to .deleted.jsonl hides it from sessions.list and token-usage + * (both already filter out filenames containing ".deleted."). + */ +function registerSessionHandlers(): void { + ipcMain.handle('session:delete', async (_, sessionKey: string) => { + try { + if (!sessionKey || !sessionKey.startsWith('agent:')) { + return { success: false, error: `Invalid sessionKey: ${sessionKey}` }; + } + + const parts = sessionKey.split(':'); + if (parts.length < 3) { + return { success: false, error: `sessionKey has too few parts: ${sessionKey}` }; + } + + const agentId = parts[1]; + const openclawConfigDir = getOpenClawConfigDir(); + const sessionsDir = join(openclawConfigDir, 'agents', agentId, 'sessions'); + const sessionsJsonPath = join(sessionsDir, 'sessions.json'); + + logger.info(`[session:delete] key=${sessionKey} agentId=${agentId}`); + logger.info(`[session:delete] sessionsJson=${sessionsJsonPath}`); + + const fsP = await import('fs/promises'); + + // ── Step 1: read sessions.json to find the UUID file for this sessionKey ── + let sessionsJson: Record = {}; + try { + const raw = await fsP.readFile(sessionsJsonPath, 'utf8'); + sessionsJson = JSON.parse(raw) as Record; + } catch (e) { + logger.warn(`[session:delete] Could not read sessions.json: ${String(e)}`); + return { success: false, error: `Could not read sessions.json: ${String(e)}` }; + } + + // sessions.json structure: try common shapes used by OpenClaw Gateway: + // Shape A (array): { sessions: [{ key, file, ... }] } + // Shape B (object): { [sessionKey]: { file, ... } } + // Shape C (array): { sessions: [{ key, id, ... }] } — id is the UUID + let uuidFileName: string | undefined; + + // Shape A / C — array under "sessions" key + if (Array.isArray(sessionsJson.sessions)) { + const entry = (sessionsJson.sessions as Array>) + .find((s) => s.key === sessionKey || s.sessionKey === sessionKey); + if (entry) { + // Could be "file", "fileName", "id" + ".jsonl", or "path" + uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined; + if (!uuidFileName && typeof entry.id === 'string') { + uuidFileName = `${entry.id}.jsonl`; + } + } + } + + // Shape B — flat object keyed by sessionKey; value may be a string or an object. + // Actual Gateway format: { sessionFile: "/abs/path/uuid.jsonl", sessionId: "uuid", ... } + let resolvedSrcPath: string | undefined; + + if (!uuidFileName && sessionsJson[sessionKey] != null) { + const val = sessionsJson[sessionKey]; + if (typeof val === 'string') { + uuidFileName = val; + } else if (typeof val === 'object' && val !== null) { + const entry = val as Record; + // Priority: absolute sessionFile path > relative file/fileName/path > id/sessionId as UUID + const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined; + if (absFile) { + if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) { + // Absolute path — use directly + resolvedSrcPath = absFile; + } else { + uuidFileName = absFile; + } + } else { + // Fall back to UUID fields + const uuidVal = (entry.id ?? entry.sessionId) as string | undefined; + if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`; + } + } + } + + if (!uuidFileName && !resolvedSrcPath) { + const rawVal = sessionsJson[sessionKey]; + logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(rawVal)}`); + return { success: false, error: `Cannot resolve file for session: ${sessionKey}` }; + } + + // Normalise: if we got a relative filename, resolve it against sessionsDir + if (!resolvedSrcPath) { + if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`; + resolvedSrcPath = join(sessionsDir, uuidFileName!); + } + + const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl'); + logger.info(`[session:delete] file: ${resolvedSrcPath}`); + + // ── Step 2: rename the JSONL file ── + try { + await fsP.access(resolvedSrcPath); + await fsP.rename(resolvedSrcPath, dstPath); + logger.info(`[session:delete] Renamed ${resolvedSrcPath} → ${dstPath}`); + } catch (e) { + logger.warn(`[session:delete] Could not rename file: ${String(e)}`); + } + + // ── Step 3: remove the entry from sessions.json ── + try { + // Re-read to avoid race conditions + const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8'); + const json2 = JSON.parse(raw2) as Record; + + if (Array.isArray(json2.sessions)) { + json2.sessions = (json2.sessions as Array>) + .filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey); + } else if (json2[sessionKey]) { + delete json2[sessionKey]; + } + + await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8'); + logger.info(`[session:delete] Removed "${sessionKey}" from sessions.json`); + } catch (e) { + logger.warn(`[session:delete] Could not update sessions.json: ${String(e)}`); + // Non-fatal — JSONL rename already done + } + + return { success: true }; + } catch (err) { + logger.error(`[session:delete] Unexpected error for ${sessionKey}:`, err); + return { success: false, error: String(err) }; + } + }); +} + diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f445014c2..f0e7fc316 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -127,6 +127,8 @@ const electronAPI = { 'media:saveImage', // Chat send with media (reads staged files in main process) 'chat:sendWithMedia', + // Session management + 'session:delete', // OpenClaw extras 'openclaw:getDir', 'openclaw:getConfigDir', diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f40ab3819..e963a46ae 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ * Navigation sidebar with menu items. * No longer fixed - sits inside the flex layout below the title bar. */ -import { NavLink } from 'react-router-dom'; +import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { Home, MessageSquare, @@ -15,9 +15,11 @@ import { ChevronRight, Terminal, ExternalLink, + Trash2, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useSettingsStore } from '@/stores/settings'; +import { useChatStore } from '@/stores/chat'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { useTranslation } from 'react-i18next'; @@ -28,12 +30,14 @@ interface NavItemProps { label: string; badge?: string; collapsed?: boolean; + onClick?: () => void; } -function NavItem({ to, icon, label, badge, collapsed }: NavItemProps) { +function NavItem({ to, icon, label, badge, collapsed, onClick }: NavItemProps) { return ( cn( 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors', @@ -65,6 +69,23 @@ export function Sidebar() { const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); + const sessions = useChatStore((s) => s.sessions); + const currentSessionKey = useChatStore((s) => s.currentSessionKey); + const sessionLabels = useChatStore((s) => s.sessionLabels); + const sessionLastActivity = useChatStore((s) => s.sessionLastActivity); + const switchSession = useChatStore((s) => s.switchSession); + const newSession = useChatStore((s) => s.newSession); + const deleteSession = useChatStore((s) => s.deleteSession); + + const navigate = useNavigate(); + const isOnChat = useLocation().pathname === '/'; + + const mainSessions = sessions.filter((s) => s.key.endsWith(':main')); + const otherSessions = sessions.filter((s) => !s.key.endsWith(':main')); + + const getSessionLabel = (key: string, displayName?: string, label?: string) => + sessionLabels[key] ?? label ?? displayName ?? key; + const openDevConsole = async () => { try { const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as { @@ -85,7 +106,6 @@ export function Sidebar() { const { t } = useTranslation(); const navItems = [ - { to: '/', icon: , label: t('sidebar.chat') }, { to: '/cron', icon: , label: t('sidebar.cronTasks') }, { to: '/skills', icon: , label: t('sidebar.skills') }, { to: '/channels', icon: , label: t('sidebar.channels') }, @@ -101,7 +121,24 @@ export function Sidebar() { )} > {/* Navigation */} -