feat(ui): delete chat conversation from sidebar

- Add deleteSession() to useChatSessions hook
- Trash icon on session rows (hover-reveal, always visible on mobile)
- Simple confirm dialog (no typing required)
- If active session deleted, switch to next or create new
- i18n strings for en/vi/zh
This commit is contained in:
viettranx committed 2026-03-19 22:39:34 +07:00
1 parent 45295bc75a
commit 886ef64aec
6 files changed
+104 -36

No files matched your search

+88 -36
View File
@@ -1,48 +1,54 @@
import { memo } from "react";
import { memo, useState } from "react";
import { useTranslation } from "react-i18next";
import { MessageSquare } from "lucide-react";
import { MessageSquare, Trash2 } from "lucide-react";
import { formatRelativeTime } from "@/lib/format";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { SessionInfo } from "@/types/session";
interface SessionSwitcherProps {
sessions: SessionInfo[];
activeKey: string;
onSelect: (key: string) => void;
onDelete?: (key: string) => void;
loading?: boolean;
}
/** Build a human-friendly label from session metadata or key */
function sessionLabel(session: SessionInfo): string {
// Prefer explicit titles/labels
if (session.metadata?.chat_title) return session.metadata.chat_title;
if (session.metadata?.display_name) return session.metadata.display_name;
if (session.label) return session.label;
// Parse the scope part from "agent:{id}:{scope}"
const parts = session.key.split(":");
const scope = parts.length >= 3 ? parts.slice(2).join(":") : session.key;
// Friendly format for known patterns
if (scope.startsWith("ws-")) {
// "ws-system-mmxarlgc" → "Chat mmxarlgc"
const segments = scope.split("-");
const shortId = segments[segments.length - 1] ?? scope;
return `Chat ${shortId}`;
}
if (scope.startsWith("ws:direct:")) {
// "ws:direct:uuid" → "Chat uuid (short)"
const uuid = scope.replace("ws:direct:", "");
return `Chat ${uuid.slice(0, 8)}`;
}
if (scope.startsWith("team:")) return `Team ${scope.replace("team:", "").slice(0, 12)}`;
if (scope.startsWith("cron:")) return `Cron ${scope.replace("cron:", "")}`;
// Fallback: truncate long scope
return scope.length > 24 ? scope.slice(0, 21) + "…" : scope;
}
export const SessionSwitcher = memo(function SessionSwitcher({ sessions, activeKey, onSelect, loading }: SessionSwitcherProps) {
const { t } = useTranslation("common");
export const SessionSwitcher = memo(function SessionSwitcher({ sessions, activeKey, onSelect, onDelete, loading }: SessionSwitcherProps) {
const { t } = useTranslation("chat");
const { t: tc } = useTranslation("common");
const [deleteTarget, setDeleteTarget] = useState<SessionInfo | null>(null);
if (sessions.length === 0 && loading) {
return (
@@ -57,38 +63,84 @@ export const SessionSwitcher = memo(function SessionSwitcher({ sessions, activeK
if (sessions.length === 0) {
return (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t("noSessions")}
{tc("noSessions")}
</div>
);
}
return (
<div className="space-y-0.5 p-1.5">
{sessions.map((session) => {
const isActive = session.key === activeKey;
const label = sessionLabel(session);
<>
<div className="space-y-0.5 p-1.5">
{sessions.map((session) => {
const isActive = session.key === activeKey;
const label = sessionLabel(session);
return (
<button
key={session.key}
type="button"
onClick={() => onSelect(session.key)}
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-[13px]">{label}</div>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span>{session.messageCount} {t("messages")}</span>
<span>·</span>
<span>{formatRelativeTime(session.updated)}</span>
return (
<button
key={session.key}
type="button"
onClick={() => onSelect(session.key)}
className={`group relative flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-[13px]">{label}</div>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span>{session.messageCount} {tc("messages")}</span>
<span>·</span>
<span>{formatRelativeTime(session.updated)}</span>
</div>
</div>
</div>
</button>
);
})}
</div>
{onDelete && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(session);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
e.preventDefault();
setDeleteTarget(session);
}
}}
className="shrink-0 rounded-md p-1.5 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 max-sm:opacity-100"
title={t("deleteChat")}
>
<Trash2 className="h-3.5 w-3.5" />
</span>
)}
</button>
);
})}
</div>
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("deleteChat")}</DialogTitle>
<DialogDescription>{t("deleteChatConfirm")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>{tc("cancel")}</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteTarget) {
onDelete?.(deleteTarget.key);
setDeleteTarget(null);
}
}}
>
{tc("delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
});
+2
View File
@@ -1,4 +1,6 @@
{
"deleteChat": "Delete chat",
"deleteChatConfirm": "This will permanently delete this conversation and all its messages.",
"newChat": "New Chat",
"openSessions": "Open sessions",
"readOnly": "Read-only — this session belongs to another user",
+2
View File
@@ -13,6 +13,8 @@
"video": "Video",
"voice": "Tin nhắn thoại"
},
"deleteChat": "Xóa cuộc trò chuyện",
"deleteChatConfirm": "Thao tác này sẽ xóa vĩnh viễn cuộc trò chuyện và tất cả tin nhắn.",
"newChat": "Trò chuyện mới",
"openSessions": "Mở phiên",
"readOnly": "Chỉ đọc — phiên này thuộc về người dùng khác",
+2
View File
@@ -13,6 +13,8 @@
"video": "视频",
"voice": "语音消息"
},
"deleteChat": "删除对话",
"deleteChatConfirm": "此操作将永久删除此对话及其所有消息。",
"newChat": "新建对话",
"openSessions": "打开Session",
"readOnly": "只读 — 此Session属于其他用户",
+3
View File
@@ -13,6 +13,7 @@ interface ChatSidebarProps {
sessionsLoading: boolean;
activeSessionKey: string;
onSessionSelect: (key: string) => void;
onDeleteSession?: (key: string) => void;
onNewChat: () => void;
}
@@ -23,6 +24,7 @@ export const ChatSidebar = memo(function ChatSidebar({
sessionsLoading,
activeSessionKey,
onSessionSelect,
onDeleteSession,
onNewChat,
}: ChatSidebarProps) {
const { t } = useTranslation("chat");
@@ -51,6 +53,7 @@ export const ChatSidebar = memo(function ChatSidebar({
sessions={sessions}
activeKey={activeSessionKey}
onSelect={onSessionSelect}
onDelete={onDeleteSession}
loading={sessionsLoading}
/>
</div>
@@ -45,11 +45,18 @@ export function useChatSessions(agentId: string) {
return `agent:${agentId}:ws:direct:${convId}`;
}, [agentId]);
const deleteSession = useCallback(async (key: string) => {
if (!connected) return;
await ws.call(Methods.SESSIONS_DELETE, { key });
await loadSessions();
}, [ws, connected, loadSessions]);
return {
sessions,
loading,
error,
refresh: loadSessions,
buildNewSessionKey,
deleteSession,
};
}