feat(chat): enhance sidebar with session management and deletion (#274)
This commit is contained in:
committed by
GitHub
Unverified
parent
f18c91fd6a
commit
c49c7f18bd
@@ -149,6 +149,9 @@ export function registerIpcHandlers(
|
|||||||
// Dialog handlers
|
// Dialog handlers
|
||||||
registerDialogHandlers();
|
registerDialogHandlers();
|
||||||
|
|
||||||
|
// Session handlers
|
||||||
|
registerSessionHandlers();
|
||||||
|
|
||||||
// App handlers
|
// App handlers
|
||||||
registerAppHandlers();
|
registerAppHandlers();
|
||||||
|
|
||||||
@@ -2112,3 +2115,143 @@ function registerFileHandlers(): void {
|
|||||||
return results;
|
return results;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session IPC handlers
|
||||||
|
*
|
||||||
|
* Performs a soft-delete of a session's JSONL transcript on disk.
|
||||||
|
* sessionKey format: "agent:<agentId>:<suffix>" — e.g. "agent:main:session-1234567890".
|
||||||
|
* The JSONL file lives at: ~/.openclaw/agents/<agentId>/sessions/<suffix>.jsonl
|
||||||
|
* Renaming to <suffix>.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<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||||
|
sessionsJson = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} 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<Record<string, unknown>>)
|
||||||
|
.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<string, unknown>;
|
||||||
|
// 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<string, unknown>;
|
||||||
|
|
||||||
|
if (Array.isArray(json2.sessions)) {
|
||||||
|
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
|
||||||
|
.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) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ const electronAPI = {
|
|||||||
'media:saveImage',
|
'media:saveImage',
|
||||||
// Chat send with media (reads staged files in main process)
|
// Chat send with media (reads staged files in main process)
|
||||||
'chat:sendWithMedia',
|
'chat:sendWithMedia',
|
||||||
|
// Session management
|
||||||
|
'session:delete',
|
||||||
// OpenClaw extras
|
// OpenClaw extras
|
||||||
'openclaw:getDir',
|
'openclaw:getDir',
|
||||||
'openclaw:getConfigDir',
|
'openclaw:getConfigDir',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Navigation sidebar with menu items.
|
* Navigation sidebar with menu items.
|
||||||
* No longer fixed - sits inside the flex layout below the title bar.
|
* 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 {
|
import {
|
||||||
Home,
|
Home,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Terminal,
|
Terminal,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useSettingsStore } from '@/stores/settings';
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
|
import { useChatStore } from '@/stores/chat';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -28,12 +30,14 @@ interface NavItemProps {
|
|||||||
label: string;
|
label: string;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavItem({ to, icon, label, badge, collapsed }: NavItemProps) {
|
function NavItem({ to, icon, label, badge, collapsed, onClick }: NavItemProps) {
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
|
onClick={onClick}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
'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 setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||||
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
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 () => {
|
const openDevConsole = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as {
|
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as {
|
||||||
@@ -85,7 +106,6 @@ export function Sidebar() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: <MessageSquare className="h-5 w-5" />, label: t('sidebar.chat') },
|
|
||||||
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: t('sidebar.cronTasks') },
|
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: t('sidebar.cronTasks') },
|
||||||
{ to: '/skills', icon: <Puzzle className="h-5 w-5" />, label: t('sidebar.skills') },
|
{ to: '/skills', icon: <Puzzle className="h-5 w-5" />, label: t('sidebar.skills') },
|
||||||
{ to: '/channels', icon: <Radio className="h-5 w-5" />, label: t('sidebar.channels') },
|
{ to: '/channels', icon: <Radio className="h-5 w-5" />, label: t('sidebar.channels') },
|
||||||
@@ -101,7 +121,24 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 space-y-1 overflow-auto p-2">
|
<nav className="flex-1 overflow-hidden flex flex-col p-2 gap-1">
|
||||||
|
{/* Chat nav item: acts as "New Chat" button, never highlighted as active */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const { messages } = useChatStore.getState();
|
||||||
|
if (messages.length > 0) newSession();
|
||||||
|
navigate('/');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground text-muted-foreground',
|
||||||
|
sidebarCollapsed && 'justify-center px-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-5 w-5 shrink-0" />
|
||||||
|
{!sidebarCollapsed && <span className="flex-1 text-left">{t('sidebar.newChat')}</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavItem
|
<NavItem
|
||||||
key={item.to}
|
key={item.to}
|
||||||
@@ -109,6 +146,50 @@ export function Sidebar() {
|
|||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Session list — below Settings, only when expanded */}
|
||||||
|
{!sidebarCollapsed && sessions.length > 0 && (
|
||||||
|
<div className="mt-1 overflow-y-auto max-h-72 space-y-0.5">
|
||||||
|
{[...mainSessions, ...[...otherSessions].sort((a, b) =>
|
||||||
|
(sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0)
|
||||||
|
)].map((s) => (
|
||||||
|
<div key={s.key} className="group relative flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => { switchSession(s.key); navigate('/'); }}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left rounded-md px-3 py-1.5 text-sm truncate transition-colors',
|
||||||
|
!s.key.endsWith(':main') && 'pr-7',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
isOnChat && currentSessionKey === s.key
|
||||||
|
? 'bg-accent/60 text-accent-foreground font-medium'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getSessionLabel(s.key, s.displayName, s.label)}
|
||||||
|
</button>
|
||||||
|
{!s.key.endsWith(':main') && (
|
||||||
|
<button
|
||||||
|
aria-label="Delete session"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const label = getSessionLabel(s.key, s.displayName, s.label);
|
||||||
|
if (!window.confirm(`Delete "${label}"?`)) return;
|
||||||
|
await deleteSession(s.key);
|
||||||
|
if (currentSessionKey === s.key) navigate('/');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
|
||||||
|
'opacity-0 group-hover:opacity-100',
|
||||||
|
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
},
|
},
|
||||||
"noLogs": "(No logs available yet)",
|
"noLogs": "(No logs available yet)",
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"newSession": "New session",
|
|
||||||
"refresh": "Refresh chat",
|
"refresh": "Refresh chat",
|
||||||
"showThinking": "Show thinking",
|
"showThinking": "Show thinking",
|
||||||
"hideThinking": "Hide thinking"
|
"hideThinking": "Hide thinking"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"chat": "Chat",
|
"chat": "Chat",
|
||||||
|
"newChat": "New Chat",
|
||||||
"cronTasks": "Cron Tasks",
|
"cronTasks": "Cron Tasks",
|
||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"channels": "Channels",
|
"channels": "Channels",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
},
|
},
|
||||||
"noLogs": "(ログはまだありません)",
|
"noLogs": "(ログはまだありません)",
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"newSession": "新しいセッション",
|
|
||||||
"refresh": "チャットを更新",
|
"refresh": "チャットを更新",
|
||||||
"showThinking": "思考を表示",
|
"showThinking": "思考を表示",
|
||||||
"hideThinking": "思考を非表示"
|
"hideThinking": "思考を非表示"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"chat": "チャット",
|
"chat": "チャット",
|
||||||
|
"newChat": "新しいチャット",
|
||||||
"cronTasks": "定期タスク",
|
"cronTasks": "定期タスク",
|
||||||
"skills": "スキル",
|
"skills": "スキル",
|
||||||
"channels": "チャンネル",
|
"channels": "チャンネル",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
},
|
},
|
||||||
"noLogs": "(暂无日志)",
|
"noLogs": "(暂无日志)",
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
"newSession": "新会话",
|
|
||||||
"refresh": "刷新聊天",
|
"refresh": "刷新聊天",
|
||||||
"showThinking": "显示思考过程",
|
"showThinking": "显示思考过程",
|
||||||
"hideThinking": "隐藏思考过程"
|
"hideThinking": "隐藏思考过程"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"chat": "聊天",
|
"chat": "聊天",
|
||||||
|
"newChat": "新对话",
|
||||||
"cronTasks": "定时任务",
|
"cronTasks": "定时任务",
|
||||||
"skills": "技能",
|
"skills": "技能",
|
||||||
"channels": "频道",
|
"channels": "频道",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Session selector, new session, refresh, and thinking toggle.
|
* Session selector, new session, refresh, and thinking toggle.
|
||||||
* Rendered in the Header when on the Chat page.
|
* Rendered in the Header when on the Chat page.
|
||||||
*/
|
*/
|
||||||
import { RefreshCw, Brain, ChevronDown, Plus } from 'lucide-react';
|
import { RefreshCw, Brain } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useChatStore } from '@/stores/chat';
|
import { useChatStore } from '@/stores/chat';
|
||||||
@@ -11,65 +11,14 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function ChatToolbar() {
|
export function ChatToolbar() {
|
||||||
const sessions = useChatStore((s) => s.sessions);
|
|
||||||
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
|
|
||||||
const switchSession = useChatStore((s) => s.switchSession);
|
|
||||||
const newSession = useChatStore((s) => s.newSession);
|
|
||||||
const refresh = useChatStore((s) => s.refresh);
|
const refresh = useChatStore((s) => s.refresh);
|
||||||
const loading = useChatStore((s) => s.loading);
|
const loading = useChatStore((s) => s.loading);
|
||||||
const showThinking = useChatStore((s) => s.showThinking);
|
const showThinking = useChatStore((s) => s.showThinking);
|
||||||
const toggleThinking = useChatStore((s) => s.toggleThinking);
|
const toggleThinking = useChatStore((s) => s.toggleThinking);
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
|
||||||
const handleSessionChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
switchSession(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Session Selector */}
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
value={currentSessionKey}
|
|
||||||
onChange={handleSessionChange}
|
|
||||||
className={cn(
|
|
||||||
'appearance-none rounded-md border border-border bg-background px-3 py-1.5 pr-8',
|
|
||||||
'text-sm text-foreground cursor-pointer',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-ring',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Render all sessions; if currentSessionKey is not in the list, add it */}
|
|
||||||
{!sessions.some((s) => s.key === currentSessionKey) && (
|
|
||||||
<option value={currentSessionKey}>
|
|
||||||
{currentSessionKey}
|
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
{sessions.map((s) => (
|
|
||||||
<option key={s.key} value={s.key}>
|
|
||||||
{s.key}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* New Session */}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={newSession}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{t('toolbar.newSession')}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Refresh */}
|
{/* Refresh */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ interface ChatState {
|
|||||||
// Sessions
|
// Sessions
|
||||||
sessions: ChatSession[];
|
sessions: ChatSession[];
|
||||||
currentSessionKey: string;
|
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
|
// Thinking
|
||||||
showThinking: boolean;
|
showThinking: boolean;
|
||||||
@@ -94,6 +98,7 @@ interface ChatState {
|
|||||||
loadSessions: () => Promise<void>;
|
loadSessions: () => Promise<void>;
|
||||||
switchSession: (key: string) => void;
|
switchSession: (key: string) => void;
|
||||||
newSession: () => void;
|
newSession: () => void;
|
||||||
|
deleteSession: (key: string) => Promise<void>;
|
||||||
loadHistory: (quiet?: boolean) => 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>;
|
sendMessage: (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => Promise<void>;
|
||||||
abortRun: () => Promise<void>;
|
abortRun: () => Promise<void>;
|
||||||
@@ -912,6 +917,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
sessions: [],
|
sessions: [],
|
||||||
currentSessionKey: DEFAULT_SESSION_KEY,
|
currentSessionKey: DEFAULT_SESSION_KEY,
|
||||||
|
sessionLabels: {},
|
||||||
|
sessionLastActivity: {},
|
||||||
|
|
||||||
showThinking: true,
|
showThinking: true,
|
||||||
thinkingLevel: null,
|
thinkingLevel: null,
|
||||||
@@ -982,6 +989,41 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (currentSessionKey !== nextSessionKey) {
|
if (currentSessionKey !== nextSessionKey) {
|
||||||
get().loadHistory();
|
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) {
|
} catch (err) {
|
||||||
console.warn('Failed to load sessions:', err);
|
console.warn('Failed to load sessions:', err);
|
||||||
@@ -991,7 +1033,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
// ── Switch session ──
|
// ── Switch session ──
|
||||||
|
|
||||||
switchSession: (key: string) => {
|
switchSession: (key: string) => {
|
||||||
set({
|
const { currentSessionKey, messages } = get();
|
||||||
|
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
|
||||||
|
set((s) => ({
|
||||||
currentSessionKey: key,
|
currentSessionKey: key,
|
||||||
messages: [],
|
messages: [],
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
@@ -1002,11 +1046,77 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
pendingFinal: false,
|
pendingFinal: false,
|
||||||
lastUserMessageAt: null,
|
lastUserMessageAt: null,
|
||||||
pendingToolImages: [],
|
pendingToolImages: [],
|
||||||
});
|
...(leavingEmpty ? {
|
||||||
// Load history for new session
|
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();
|
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 ──
|
// ── New session ──
|
||||||
|
|
||||||
newSession: () => {
|
newSession: () => {
|
||||||
@@ -1014,12 +1124,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
// NOTE: We intentionally do NOT call sessions.reset on the old session.
|
// NOTE: We intentionally do NOT call sessions.reset on the old session.
|
||||||
// sessions.reset archives (renames) the session JSONL file, making old
|
// sessions.reset archives (renames) the session JSONL file, making old
|
||||||
// conversation history inaccessible when the user switches back to it.
|
// 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 prefix = getCanonicalPrefixFromSessions(get().sessions) ?? DEFAULT_CANONICAL_PREFIX;
|
||||||
const newKey = `${prefix}:session-${Date.now()}`;
|
const newKey = `${prefix}:session-${Date.now()}`;
|
||||||
const newSessionEntry: ChatSession = { key: newKey, displayName: newKey };
|
const newSessionEntry: ChatSession = { key: newKey, displayName: newKey };
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
currentSessionKey: newKey,
|
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: [],
|
messages: [],
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
streamingMessage: null,
|
streamingMessage: null,
|
||||||
@@ -1079,6 +1200,32 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
set({ messages: finalMessages, thinkingLevel, loading: false });
|
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)
|
// Async: load missing image previews from disk (updates in background)
|
||||||
loadMissingPreviews(finalMessages).then((updated) => {
|
loadMissingPreviews(finalMessages).then((updated) => {
|
||||||
if (updated) {
|
if (updated) {
|
||||||
@@ -1170,6 +1317,17 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
lastUserMessageAt: nowMs,
|
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
|
// Start the history poll and safety timeout IMMEDIATELY (before the
|
||||||
// RPC await) because the gateway's chat.send RPC may block until the
|
// RPC await) because the gateway's chat.send RPC may block until the
|
||||||
// entire agentic conversation finishes — the poll must run in parallel.
|
// entire agentic conversation finishes — the poll must run in parallel.
|
||||||
|
|||||||
Reference in New Issue
Block a user