feat(chat): enhance sidebar with session management and deletion (#274)

This commit is contained in:
DigHuang
2026-03-03 19:07:42 +08:00
committed by GitHub
Unverified
parent f18c91fd6a
commit c49c7f18bd
11 changed files with 396 additions and 63 deletions

View File

@@ -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 (
<NavLink
to={to}
onClick={onClick}
className={({ isActive }) =>
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: <MessageSquare className="h-5 w-5" />, label: t('sidebar.chat') },
{ 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: '/channels', icon: <Radio className="h-5 w-5" />, label: t('sidebar.channels') },
@@ -101,7 +121,24 @@ export function Sidebar() {
)}
>
{/* 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) => (
<NavItem
key={item.to}
@@ -109,6 +146,50 @@ export function Sidebar() {
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>
{/* Footer */}

View File

@@ -11,7 +11,6 @@
},
"noLogs": "(No logs available yet)",
"toolbar": {
"newSession": "New session",
"refresh": "Refresh chat",
"showThinking": "Show thinking",
"hideThinking": "Hide thinking"

View File

@@ -1,6 +1,7 @@
{
"sidebar": {
"chat": "Chat",
"newChat": "New Chat",
"cronTasks": "Cron Tasks",
"skills": "Skills",
"channels": "Channels",

View File

@@ -11,7 +11,6 @@
},
"noLogs": "(ログはまだありません)",
"toolbar": {
"newSession": "新しいセッション",
"refresh": "チャットを更新",
"showThinking": "思考を表示",
"hideThinking": "思考を非表示"

View File

@@ -1,6 +1,7 @@
{
"sidebar": {
"chat": "チャット",
"newChat": "新しいチャット",
"cronTasks": "定期タスク",
"skills": "スキル",
"channels": "チャンネル",

View File

@@ -11,7 +11,6 @@
},
"noLogs": "(暂无日志)",
"toolbar": {
"newSession": "新会话",
"refresh": "刷新聊天",
"showThinking": "显示思考过程",
"hideThinking": "隐藏思考过程"

View File

@@ -1,6 +1,7 @@
{
"sidebar": {
"chat": "聊天",
"newChat": "新对话",
"cronTasks": "定时任务",
"skills": "技能",
"channels": "频道",

View File

@@ -3,7 +3,7 @@
* Session selector, new session, refresh, and thinking toggle.
* 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useChatStore } from '@/stores/chat';
@@ -11,65 +11,14 @@ import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
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 loading = useChatStore((s) => s.loading);
const showThinking = useChatStore((s) => s.showThinking);
const toggleThinking = useChatStore((s) => s.toggleThinking);
const { t } = useTranslation('chat');
const handleSessionChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
switchSession(e.target.value);
};
return (
<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 */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -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.