mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
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:
1 parent
45295bc75a
commit
886ef64aec
6 files changed
+104
-36
No files matched your search
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"video": "视频",
|
||||
"voice": "语音消息"
|
||||
},
|
||||
"deleteChat": "删除对话",
|
||||
"deleteChatConfirm": "此操作将永久删除此对话及其所有消息。",
|
||||
"newChat": "新建对话",
|
||||
"openSessions": "打开Session",
|
||||
"readOnly": "只读 — 此Session属于其他用户",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user