From 886ef64aec37f29d2add0ca09d14ed98e9eaf0e7 Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 19 Mar 2026 22:39:34 +0700 Subject: [PATCH] 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 --- .../src/components/chat/session-switcher.tsx | 124 +++++++++++++----- ui/web/src/i18n/locales/en/chat.json | 2 + ui/web/src/i18n/locales/vi/chat.json | 2 + ui/web/src/i18n/locales/zh/chat.json | 2 + ui/web/src/pages/chat/chat-sidebar.tsx | 3 + .../src/pages/chat/hooks/use-chat-sessions.ts | 7 + 6 files changed, 104 insertions(+), 36 deletions(-) diff --git a/ui/web/src/components/chat/session-switcher.tsx b/ui/web/src/components/chat/session-switcher.tsx index 2ffaf89c..2e807843 100644 --- a/ui/web/src/components/chat/session-switcher.tsx +++ b/ui/web/src/components/chat/session-switcher.tsx @@ -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(null); if (sessions.length === 0 && loading) { return ( @@ -57,38 +63,84 @@ export const SessionSwitcher = memo(function SessionSwitcher({ sessions, activeK if (sessions.length === 0) { return (
- {t("noSessions")} + {tc("noSessions")}
); } return ( -
- {sessions.map((session) => { - const isActive = session.key === activeKey; - const label = sessionLabel(session); + <> +
+ {sessions.map((session) => { + const isActive = session.key === activeKey; + const label = sessionLabel(session); - return ( -
- - ); - })} -
+ {onDelete && ( + { + 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")} + > + + + )} + + ); + })} + + + !open && setDeleteTarget(null)}> + + + {t("deleteChat")} + {t("deleteChatConfirm")} + + + + + + + + ); }); diff --git a/ui/web/src/i18n/locales/en/chat.json b/ui/web/src/i18n/locales/en/chat.json index 3d512ee0..2c8e7c20 100644 --- a/ui/web/src/i18n/locales/en/chat.json +++ b/ui/web/src/i18n/locales/en/chat.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/chat.json b/ui/web/src/i18n/locales/vi/chat.json index 9d5ffd5c..5441be2b 100644 --- a/ui/web/src/i18n/locales/vi/chat.json +++ b/ui/web/src/i18n/locales/vi/chat.json @@ -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", diff --git a/ui/web/src/i18n/locales/zh/chat.json b/ui/web/src/i18n/locales/zh/chat.json index b9328a3b..a4b6e867 100644 --- a/ui/web/src/i18n/locales/zh/chat.json +++ b/ui/web/src/i18n/locales/zh/chat.json @@ -13,6 +13,8 @@ "video": "视频", "voice": "语音消息" }, + "deleteChat": "删除对话", + "deleteChatConfirm": "此操作将永久删除此对话及其所有消息。", "newChat": "新建对话", "openSessions": "打开Session", "readOnly": "只读 — 此Session属于其他用户", diff --git a/ui/web/src/pages/chat/chat-sidebar.tsx b/ui/web/src/pages/chat/chat-sidebar.tsx index 43b24ff7..b37026a9 100644 --- a/ui/web/src/pages/chat/chat-sidebar.tsx +++ b/ui/web/src/pages/chat/chat-sidebar.tsx @@ -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} /> diff --git a/ui/web/src/pages/chat/hooks/use-chat-sessions.ts b/ui/web/src/pages/chat/hooks/use-chat-sessions.ts index 8d40fa52..2fcb22b0 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-sessions.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-sessions.ts @@ -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, }; }