feat(sidebar): add conversation management dialog with filtering and bulk actions

This commit is contained in:
xintaofei
2026-04-22 09:31:38 +08:00
parent ae88b2256f
commit 14fb231dcc
13 changed files with 760 additions and 2 deletions

View File

@@ -251,4 +251,4 @@ pub async fn update_conversation_external_id(
.await .await
.map_err(AppCommandError::from)?; .map_err(AppCommandError::from)?;
Ok(Json(())) Ok(Json(()))
} }

View File

@@ -0,0 +1,500 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
CheckSquare,
ChevronDown,
ListChecks,
Loader2,
Square,
Trash2,
} from "lucide-react"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Skeleton } from "@/components/ui/skeleton"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { AgentIcon } from "@/components/agent-icon"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useTabContext } from "@/contexts/tab-context"
import {
deleteConversation,
listAllConversations,
updateConversationStatus,
} from "@/lib/api"
import type {
AgentType,
ConversationStatus,
DbConversationSummary,
} from "@/lib/types"
import {
AGENT_LABELS,
ALL_AGENT_TYPES,
STATUS_COLORS,
STATUS_ORDER,
} from "@/lib/types"
import { cn } from "@/lib/utils"
interface ConversationManageDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
folderId: number
folderName: string
}
function parseTimestamp(value: string): number {
const ts = Date.parse(value)
return Number.isNaN(ts) ? 0 : ts
}
function formatRelative(iso: string): string {
const ts = parseTimestamp(iso)
if (!ts) return ""
const diff = Math.max(0, Date.now() - ts)
const m = Math.floor(diff / 60000)
if (m < 1) return "now"
if (m < 60) return `${m}m`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h`
const d = Math.floor(h / 24)
if (d < 30) return `${d}d`
const mo = Math.floor(d / 30)
if (mo < 12) return `${mo}mo`
const y = Math.floor(mo / 12)
return `${y}y`
}
export function ConversationManageDialog({
open,
onOpenChange,
folderId,
folderName,
}: ConversationManageDialogProps) {
const t = useTranslations("Folder.sidebar.manageConversations")
const tCommon = useTranslations("Folder.common")
const tStatus = useTranslations("Folder.statusLabels")
const { refreshConversations } = useAppWorkspace()
const { closeConversationTab } = useTabContext()
const [search, setSearch] = useState("")
const [agentFilter, setAgentFilter] = useState<AgentType | "all">("all")
const [statusFilter, setStatusFilter] = useState<ConversationStatus | "all">(
"all"
)
const [rows, setRows] = useState<DbConversationSummary[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [pending, setPending] = useState(false)
const [refreshKey, setRefreshKey] = useState(0)
const [confirmDelete, setConfirmDelete] = useState(false)
// Reset state on open/close transitions
useEffect(() => {
if (!open) {
setSearch("")
setAgentFilter("all")
setStatusFilter("all")
setSelected(new Set())
setConfirmDelete(false)
setError(null)
}
}, [open])
// Debounced data fetch
useEffect(() => {
if (!open) return
const timer = setTimeout(async () => {
setLoading(true)
try {
const data = await listAllConversations({
folder_ids: [folderId],
search: search.trim() || null,
agent_type: agentFilter === "all" ? null : agentFilter,
status: statusFilter === "all" ? null : statusFilter,
})
setRows(data)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, 300)
return () => clearTimeout(timer)
}, [open, folderId, search, agentFilter, statusFilter, refreshKey])
const toggleOne = useCallback((id: number) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const allVisibleSelected = useMemo(
() => rows.length > 0 && rows.every((r) => selected.has(r.id)),
[rows, selected]
)
const toggleSelectAll = useCallback(() => {
if (allVisibleSelected) {
setSelected((prev) => {
const next = new Set(prev)
for (const r of rows) next.delete(r.id)
return next
})
} else {
setSelected((prev) => {
const next = new Set(prev)
for (const r of rows) next.add(r.id)
return next
})
}
}, [allVisibleSelected, rows])
const afterBulkOp = useCallback(() => {
setSelected(new Set())
setRefreshKey((k) => k + 1)
refreshConversations()
}, [refreshConversations])
const selectedIds = useMemo(() => [...selected], [selected])
const selectedCount = selected.size
const handleBulkDelete = useCallback(async () => {
if (selectedIds.length === 0) return
setPending(true)
try {
const affected = rows.filter((r) => selected.has(r.id))
await Promise.all(selectedIds.map((id) => deleteConversation(id)))
for (const conv of affected) {
closeConversationTab(conv.folder_id, conv.id, conv.agent_type)
}
toast.success(t("toastDeleted", { count: selectedIds.length }))
afterBulkOp()
} catch (e) {
toast.error(
t("toastOpFailed", {
message: e instanceof Error ? e.message : String(e),
})
)
} finally {
setPending(false)
setConfirmDelete(false)
}
}, [selectedIds, rows, selected, closeConversationTab, t, afterBulkOp])
const handleBulkStatus = useCallback(
async (status: ConversationStatus) => {
if (selectedIds.length === 0) return
setPending(true)
try {
await Promise.all(
selectedIds.map((id) => updateConversationStatus(id, status))
)
toast.success(t("toastStatusUpdated", { count: selectedIds.length }))
afterBulkOp()
} catch (e) {
toast.error(
t("toastOpFailed", {
message: e instanceof Error ? e.message : String(e),
})
)
} finally {
setPending(false)
}
},
[selectedIds, t, afterBulkOp]
)
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t("title", { name: folderName })}</DialogTitle>
</DialogHeader>
{/* Filter row */}
<div className="flex items-center justify-between gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
className="h-9 w-64"
/>
<div className="flex items-center gap-2">
<Select
value={agentFilter}
onValueChange={(v) => setAgentFilter(v as AgentType | "all")}
>
<SelectTrigger className="h-9 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("agentFilterAll")}</SelectItem>
{ALL_AGENT_TYPES.map((at) => (
<SelectItem key={at} value={at}>
<span className="flex items-center gap-2">
<AgentIcon agentType={at} className="h-3.5 w-3.5" />
{AGENT_LABELS[at]}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as ConversationStatus | "all")
}
>
<SelectTrigger className="h-9 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("statusFilterAll")}</SelectItem>
{STATUS_ORDER.map((s) => (
<SelectItem key={s} value={s}>
<span className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 rounded-full",
STATUS_COLORS[s]
)}
/>
{tStatus(s)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* List container: select-all header + scrollable list */}
<div className="flex flex-col rounded-md border border-border/50 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50 bg-muted/20">
<button
type="button"
onClick={toggleSelectAll}
disabled={rows.length === 0}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
>
<span className="flex h-5 w-5 items-center justify-center">
{allVisibleSelected ? (
<CheckSquare className="h-4 w-4 text-primary" />
) : (
<Square className="h-4 w-4" />
)}
</span>
{allVisibleSelected ? t("deselectAll") : t("selectAllVisible")}
</button>
<span className="text-xs text-muted-foreground">
{t("matchedCount", { count: rows.length })}
</span>
</div>
<ScrollArea className="h-[26rem]">
<div className="flex flex-col gap-0.5 p-1">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-full rounded-md" />
))
) : error ? (
<p className="text-destructive text-sm px-3 py-6 text-center">
{error}
</p>
) : rows.length === 0 ? (
<p className="text-muted-foreground text-sm px-3 py-6 text-center">
{search.trim() ||
agentFilter !== "all" ||
statusFilter !== "all"
? t("noMatchingConversations")
: t("noConversations")}
</p>
) : (
rows.map((conv) => {
const checked = selected.has(conv.id)
return (
<div
key={conv.id}
onClick={() => toggleOne(conv.id)}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer border border-transparent",
"hover:bg-accent/50",
checked && "bg-accent/40 border-accent/60"
)}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleOne(conv.id)
}}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
aria-pressed={checked}
>
{checked ? (
<CheckSquare className="h-4 w-4 text-primary" />
) : (
<Square className="h-4 w-4" />
)}
</button>
<AgentIcon
agentType={conv.agent_type}
className="h-4 w-4 shrink-0"
/>
<span className="flex-1 min-w-0 truncate text-sm">
{conv.title || t("untitledConversation")}
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums w-14 text-right">
{t("messagesShort", { count: conv.message_count })}
</span>
<span className="shrink-0 text-xs text-muted-foreground w-10 text-right">
{formatRelative(conv.updated_at)}
</span>
<span
className={cn(
"shrink-0 h-2 w-2 rounded-full",
STATUS_COLORS[conv.status as ConversationStatus] ??
"bg-gray-400"
)}
title={
STATUS_ORDER.includes(
conv.status as ConversationStatus
)
? tStatus(conv.status as ConversationStatus)
: conv.status
}
/>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
{/* Footer: bulk actions */}
<DialogFooter className="flex items-center justify-between gap-2 sm:justify-between">
<span className="text-xs text-muted-foreground">
{t("selectedCount", { count: selectedCount })}
</span>
<div className="flex flex-wrap items-center gap-2">
{/* Set status */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
disabled={selectedCount === 0 || pending}
>
<ListChecks className="h-3.5 w-3.5 mr-1" />
{t("setStatus")}
<ChevronDown className="h-3 w-3 ml-1 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{STATUS_ORDER.map((s) => (
<DropdownMenuItem
key={s}
onSelect={() => handleBulkStatus(s)}
>
<span
className={cn(
"h-2 w-2 rounded-full mr-2",
STATUS_COLORS[s]
)}
/>
{tStatus(s)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Delete */}
<Button
size="sm"
variant="destructive"
disabled={selectedCount === 0 || pending}
onClick={() => setConfirmDelete(true)}
>
{pending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5 mr-1" />
)}
{t("deleteSelected")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onOpenChange(false)}
>
{tCommon("close")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<AlertDialog
open={confirmDelete}
onOpenChange={(o) => !o && setConfirmDelete(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("confirmDeleteTitle", { count: selectedCount })}
</AlertDialogTitle>
<AlertDialogDescription>
{t("confirmDeleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDelete}>
{tCommon("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -13,7 +13,14 @@ import {
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { Virtualizer, type VirtualizerHandle } from "virtua" import { Virtualizer, type VirtualizerHandle } from "virtua"
import { ChevronRight, Download, Loader2, Plus, XCircle } from "lucide-react" import {
ChevronRight,
Download,
ListChecks,
Loader2,
Plus,
XCircle,
} from "lucide-react"
import { useActiveFolder } from "@/contexts/active-folder-context" import { useActiveFolder } from "@/contexts/active-folder-context"
import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useTabContext } from "@/contexts/tab-context" import { useTabContext } from "@/contexts/tab-context"
@@ -31,6 +38,7 @@ import {
saveFolderExpanded, saveFolderExpanded,
} from "@/lib/sidebar-view-mode-storage" } from "@/lib/sidebar-view-mode-storage"
import { SidebarConversationCard } from "./sidebar-conversation-card" import { SidebarConversationCard } from "./sidebar-conversation-card"
import { ConversationManageDialog } from "./conversation-manage-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
@@ -112,6 +120,7 @@ const FolderHeader = memo(function FolderHeader({
onRemoveFromWorkspace, onRemoveFromWorkspace,
onNewConversation, onNewConversation,
onImport, onImport,
onManageConversations,
t, t,
}: { }: {
folderId: number folderId: number
@@ -123,6 +132,7 @@ const FolderHeader = memo(function FolderHeader({
onRemoveFromWorkspace: (folderId: number) => void onRemoveFromWorkspace: (folderId: number) => void
onNewConversation: (folderId: number) => void onNewConversation: (folderId: number) => void
onImport: (folderId: number) => void onImport: (folderId: number) => void
onManageConversations: (folderId: number) => void
t: ReturnType<typeof useTranslations> t: ReturnType<typeof useTranslations>
}) { }) {
return ( return (
@@ -216,6 +226,11 @@ const FolderHeader = memo(function FolderHeader({
{importing ? t("importing") : t("importLocalSessions")} {importing ? t("importing") : t("importLocalSessions")}
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem onSelect={() => onManageConversations(folderId)}>
<ListChecks className="h-4 w-4" />
{t("folderHeaderMenu.manageConversations")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
variant="destructive" variant="destructive"
onSelect={() => onRemoveFromWorkspace(folderId)} onSelect={() => onRemoveFromWorkspace(folderId)}
@@ -301,6 +316,10 @@ export function SidebarConversationList({
folderId: number folderId: number
folderName: string folderName: string
} | null>(null) } | null>(null)
const [manageState, setManageState] = useState<{
folderId: number
folderName: string
} | null>(null)
useEffect(() => { useEffect(() => {
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent. // Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
@@ -483,6 +502,14 @@ export function SidebarConversationList({
[folderIndex] [folderIndex]
) )
const handleManageConversations = useCallback(
(folderId: number) => {
const name = folderIndex.get(folderId)?.name ?? String(folderId)
setManageState({ folderId, folderName: name })
},
[folderIndex]
)
const handleRemoveFolderConfirm = useCallback(async () => { const handleRemoveFolderConfirm = useCallback(async () => {
if (!removeConfirm) return if (!removeConfirm) return
const { folderId, folderName } = removeConfirm const { folderId, folderName } = removeConfirm
@@ -705,6 +732,7 @@ export function SidebarConversationList({
onRemoveFromWorkspace={handleRemoveFolder} onRemoveFromWorkspace={handleRemoveFolder}
onNewConversation={handleNewConversationForFolder} onNewConversation={handleNewConversationForFolder}
onImport={handleImportForFolder} onImport={handleImportForFolder}
onManageConversations={handleManageConversations}
t={t} t={t}
/> />
</div> </div>
@@ -735,6 +763,7 @@ export function SidebarConversationList({
onRemoveFromWorkspace={handleRemoveFolder} onRemoveFromWorkspace={handleRemoveFolder}
onNewConversation={handleNewConversationForFolder} onNewConversation={handleNewConversationForFolder}
onImport={handleImportForFolder} onImport={handleImportForFolder}
onManageConversations={handleManageConversations}
t={t} t={t}
/> />
) )
@@ -795,6 +824,15 @@ export function SidebarConversationList({
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{manageState && (
<ConversationManageDialog
open
onOpenChange={(o) => !o && setManageState(null)}
folderId={manageState.folderId}
folderName={manageState.folderName}
/>
)}
</div> </div>
) )
} }

View File

@@ -796,7 +796,29 @@
"removeFolderConfirmTitle": "إزالة المجلد من مساحة العمل؟", "removeFolderConfirmTitle": "إزالة المجلد من مساحة العمل؟",
"removeFolderConfirmDescription": "إزالة \"{name}\" من مساحة العمل؟ سيتم إغلاق علامات التبويب والمحطات المرتبطة.", "removeFolderConfirmDescription": "إزالة \"{name}\" من مساحة العمل؟ سيتم إغلاق علامات التبويب والمحطات المرتبطة.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "إدارة المحادثات…",
"removeFromWorkspace": "إزالة من مساحة العمل" "removeFromWorkspace": "إزالة من مساحة العمل"
},
"manageConversations": {
"title": "إدارة المحادثات — {name}",
"searchPlaceholder": "البحث بالعنوان…",
"agentFilterAll": "جميع الوكلاء",
"statusFilterAll": "جميع الحالات",
"selectAllVisible": "تحديد الكل",
"deselectAll": "إلغاء التحديد",
"selectedCount": "{count} محدد",
"matchedCount": "{count} مطابق",
"messagesShort": "{count} رسالة",
"untitledConversation": "محادثة بدون عنوان",
"setStatus": "تعيين الحالة…",
"deleteSelected": "حذف",
"noConversations": "لا توجد محادثات في هذا المجلد.",
"noMatchingConversations": "لا توجد محادثات مطابقة للفلاتر.",
"confirmDeleteTitle": "حذف {count} محادثة؟",
"confirmDeleteDescription": "لا يمكن التراجع عن هذا الإجراء.",
"toastDeleted": "تم حذف {count} محادثة",
"toastStatusUpdated": "تم تحديث حالة {count} محادثة",
"toastOpFailed": "فشلت العملية: {message}"
} }
}, },
"conversation": { "conversation": {

View File

@@ -796,7 +796,29 @@
"removeFolderConfirmTitle": "Ordner aus Arbeitsbereich entfernen?", "removeFolderConfirmTitle": "Ordner aus Arbeitsbereich entfernen?",
"removeFolderConfirmDescription": "\"{name}\" aus dem Arbeitsbereich entfernen? Zugehörige Tabs und Terminals werden geschlossen.", "removeFolderConfirmDescription": "\"{name}\" aus dem Arbeitsbereich entfernen? Zugehörige Tabs und Terminals werden geschlossen.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "Konversationen verwalten…",
"removeFromWorkspace": "Aus Arbeitsbereich entfernen" "removeFromWorkspace": "Aus Arbeitsbereich entfernen"
},
"manageConversations": {
"title": "Konversationen verwalten — {name}",
"searchPlaceholder": "Nach Titel suchen…",
"agentFilterAll": "Alle Agenten",
"statusFilterAll": "Alle Status",
"selectAllVisible": "Alle auswählen",
"deselectAll": "Auswahl aufheben",
"selectedCount": "{count} ausgewählt",
"matchedCount": "{count} Treffer",
"messagesShort": "{count} Nachr.",
"untitledConversation": "Unbenannte Konversation",
"setStatus": "Status setzen…",
"deleteSelected": "Löschen",
"noConversations": "Keine Konversationen in diesem Ordner.",
"noMatchingConversations": "Keine Konversationen entsprechen den Filtern.",
"confirmDeleteTitle": "{count} Konversation(en) löschen?",
"confirmDeleteDescription": "Diese Aktion kann nicht rückgängig gemacht werden.",
"toastDeleted": "{count} Konversation(en) gelöscht",
"toastStatusUpdated": "Status von {count} Konversation(en) aktualisiert",
"toastOpFailed": "Aktion fehlgeschlagen: {message}"
} }
}, },
"conversation": { "conversation": {

View File

@@ -796,7 +796,29 @@
"removeFolderConfirmTitle": "Remove folder from workspace?", "removeFolderConfirmTitle": "Remove folder from workspace?",
"removeFolderConfirmDescription": "Remove \"{name}\" from the workspace? Its tabs and terminals will close.", "removeFolderConfirmDescription": "Remove \"{name}\" from the workspace? Its tabs and terminals will close.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "Manage conversations…",
"removeFromWorkspace": "Remove from workspace" "removeFromWorkspace": "Remove from workspace"
},
"manageConversations": {
"title": "Manage conversations — {name}",
"searchPlaceholder": "Search by title…",
"agentFilterAll": "All agents",
"statusFilterAll": "All statuses",
"selectAllVisible": "Select all",
"deselectAll": "Deselect all",
"selectedCount": "{count} selected",
"matchedCount": "{count} matched",
"messagesShort": "{count} msg",
"untitledConversation": "Untitled conversation",
"setStatus": "Set status…",
"deleteSelected": "Delete",
"noConversations": "No conversations in this folder.",
"noMatchingConversations": "No conversations match the filters.",
"confirmDeleteTitle": "Delete {count} conversation(s)?",
"confirmDeleteDescription": "This action cannot be undone.",
"toastDeleted": "Deleted {count} conversation(s)",
"toastStatusUpdated": "Updated status for {count} conversation(s)",
"toastOpFailed": "Operation failed: {message}"
} }
}, },
"conversation": { "conversation": {

View File

@@ -796,7 +796,29 @@
"removeFolderConfirmTitle": "¿Eliminar carpeta del espacio de trabajo?", "removeFolderConfirmTitle": "¿Eliminar carpeta del espacio de trabajo?",
"removeFolderConfirmDescription": "¿Eliminar \"{name}\" del espacio de trabajo? Sus pestañas y terminales se cerrarán.", "removeFolderConfirmDescription": "¿Eliminar \"{name}\" del espacio de trabajo? Sus pestañas y terminales se cerrarán.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "Gestionar conversaciones…",
"removeFromWorkspace": "Quitar del espacio de trabajo" "removeFromWorkspace": "Quitar del espacio de trabajo"
},
"manageConversations": {
"title": "Gestionar conversaciones — {name}",
"searchPlaceholder": "Buscar por título…",
"agentFilterAll": "Todos los agentes",
"statusFilterAll": "Todos los estados",
"selectAllVisible": "Seleccionar todo",
"deselectAll": "Deseleccionar todo",
"selectedCount": "{count} seleccionada(s)",
"matchedCount": "{count} coincidencia(s)",
"messagesShort": "{count} msg",
"untitledConversation": "Conversación sin título",
"setStatus": "Cambiar estado…",
"deleteSelected": "Eliminar",
"noConversations": "No hay conversaciones en esta carpeta.",
"noMatchingConversations": "Ninguna conversación coincide con los filtros.",
"confirmDeleteTitle": "¿Eliminar {count} conversación(es)?",
"confirmDeleteDescription": "Esta acción no se puede deshacer.",
"toastDeleted": "Se eliminaron {count} conversación(es)",
"toastStatusUpdated": "Estado actualizado para {count} conversación(es)",
"toastOpFailed": "Error en la operación: {message}"
} }
}, },
"conversation": { "conversation": {

View File

@@ -796,7 +796,29 @@
"removeFolderConfirmTitle": "Retirer le dossier de l'espace de travail ?", "removeFolderConfirmTitle": "Retirer le dossier de l'espace de travail ?",
"removeFolderConfirmDescription": "Retirer \"{name}\" de l'espace de travail ? Les onglets et terminaux associés seront fermés.", "removeFolderConfirmDescription": "Retirer \"{name}\" de l'espace de travail ? Les onglets et terminaux associés seront fermés.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "Gérer les conversations…",
"removeFromWorkspace": "Retirer de l'espace de travail" "removeFromWorkspace": "Retirer de l'espace de travail"
},
"manageConversations": {
"title": "Gérer les conversations — {name}",
"searchPlaceholder": "Rechercher par titre…",
"agentFilterAll": "Tous les agents",
"statusFilterAll": "Tous les statuts",
"selectAllVisible": "Tout sélectionner",
"deselectAll": "Tout désélectionner",
"selectedCount": "{count} sélectionnée(s)",
"matchedCount": "{count} correspondance(s)",
"messagesShort": "{count} msg",
"untitledConversation": "Conversation sans titre",
"setStatus": "Définir le statut…",
"deleteSelected": "Supprimer",
"noConversations": "Aucune conversation dans ce dossier.",
"noMatchingConversations": "Aucune conversation ne correspond aux filtres.",
"confirmDeleteTitle": "Supprimer {count} conversation(s) ?",
"confirmDeleteDescription": "Cette action est irréversible.",
"toastDeleted": "{count} conversation(s) supprimée(s)",
"toastStatusUpdated": "Statut mis à jour pour {count} conversation(s)",
"toastOpFailed": "Échec de l'opération : {message}"
} }
}, },
"conversation": { "conversation": {

View File

@@ -796,7 +796,29 @@
"removeFolderConfirmTitle": "このフォルダをワークスペースから削除しますか?", "removeFolderConfirmTitle": "このフォルダをワークスペースから削除しますか?",
"removeFolderConfirmDescription": "\"{name}\" をワークスペースから削除しますか?関連するタブとターミナルが閉じられます。", "removeFolderConfirmDescription": "\"{name}\" をワークスペースから削除しますか?関連するタブとターミナルが閉じられます。",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "会話の管理…",
"removeFromWorkspace": "ワークスペースから削除" "removeFromWorkspace": "ワークスペースから削除"
},
"manageConversations": {
"title": "会話の管理 — {name}",
"searchPlaceholder": "タイトルで検索…",
"agentFilterAll": "すべてのエージェント",
"statusFilterAll": "すべてのステータス",
"selectAllVisible": "すべて選択",
"deselectAll": "選択を解除",
"selectedCount": "{count} 件選択中",
"matchedCount": "{count} 件該当",
"messagesShort": "{count} 件",
"untitledConversation": "無題の会話",
"setStatus": "ステータスを変更…",
"deleteSelected": "削除",
"noConversations": "このフォルダには会話がありません。",
"noMatchingConversations": "条件に一致する会話がありません。",
"confirmDeleteTitle": "{count} 件の会話を削除しますか?",
"confirmDeleteDescription": "この操作は元に戻せません。",
"toastDeleted": "{count} 件の会話を削除しました",
"toastStatusUpdated": "{count} 件の会話のステータスを更新しました",
"toastOpFailed": "操作に失敗しました: {message}"
} }
}, },
"conversation": { "conversation": {

View File

@@ -796,7 +796,29 @@
"removeFolderConfirmTitle": "이 폴더를 워크스페이스에서 제거하시겠습니까?", "removeFolderConfirmTitle": "이 폴더를 워크스페이스에서 제거하시겠습니까?",
"removeFolderConfirmDescription": "워크스페이스에서 \"{name}\"을(를) 제거하시겠습니까? 관련 탭과 터미널이 닫힙니다.", "removeFolderConfirmDescription": "워크스페이스에서 \"{name}\"을(를) 제거하시겠습니까? 관련 탭과 터미널이 닫힙니다.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "대화 관리…",
"removeFromWorkspace": "워크스페이스에서 제거" "removeFromWorkspace": "워크스페이스에서 제거"
},
"manageConversations": {
"title": "대화 관리 — {name}",
"searchPlaceholder": "제목으로 검색…",
"agentFilterAll": "모든 에이전트",
"statusFilterAll": "모든 상태",
"selectAllVisible": "전체 선택",
"deselectAll": "선택 해제",
"selectedCount": "{count}개 선택됨",
"matchedCount": "{count}개 일치",
"messagesShort": "{count}개",
"untitledConversation": "제목 없는 대화",
"setStatus": "상태 변경…",
"deleteSelected": "삭제",
"noConversations": "이 폴더에 대화가 없습니다.",
"noMatchingConversations": "필터에 일치하는 대화가 없습니다.",
"confirmDeleteTitle": "{count}개 대화를 삭제하시겠습니까?",
"confirmDeleteDescription": "이 작업은 되돌릴 수 없습니다.",
"toastDeleted": "{count}개 대화를 삭제했습니다",
"toastStatusUpdated": "{count}개 대화의 상태를 업데이트했습니다",
"toastOpFailed": "작업 실패: {message}"
} }
}, },
"conversation": { "conversation": {

View File

@@ -796,7 +796,29 @@
"removeFolderConfirmTitle": "Remover pasta do espaço de trabalho?", "removeFolderConfirmTitle": "Remover pasta do espaço de trabalho?",
"removeFolderConfirmDescription": "Remover \"{name}\" do espaço de trabalho? As abas e terminais relacionados serão fechados.", "removeFolderConfirmDescription": "Remover \"{name}\" do espaço de trabalho? As abas e terminais relacionados serão fechados.",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "Gerenciar conversas…",
"removeFromWorkspace": "Remover do espaço de trabalho" "removeFromWorkspace": "Remover do espaço de trabalho"
},
"manageConversations": {
"title": "Gerenciar conversas — {name}",
"searchPlaceholder": "Pesquisar por título…",
"agentFilterAll": "Todos os agentes",
"statusFilterAll": "Todos os status",
"selectAllVisible": "Selecionar tudo",
"deselectAll": "Desmarcar tudo",
"selectedCount": "{count} selecionada(s)",
"matchedCount": "{count} correspondência(s)",
"messagesShort": "{count} msg",
"untitledConversation": "Conversa sem título",
"setStatus": "Definir status…",
"deleteSelected": "Excluir",
"noConversations": "Sem conversas nesta pasta.",
"noMatchingConversations": "Nenhuma conversa corresponde aos filtros.",
"confirmDeleteTitle": "Excluir {count} conversa(s)?",
"confirmDeleteDescription": "Esta ação não pode ser desfeita.",
"toastDeleted": "{count} conversa(s) excluída(s)",
"toastStatusUpdated": "Status atualizado para {count} conversa(s)",
"toastOpFailed": "Falha na operação: {message}"
} }
}, },
"conversation": { "conversation": {

View File

@@ -796,7 +796,29 @@
"removeFolderConfirmTitle": "从工作区移除该文件夹?", "removeFolderConfirmTitle": "从工作区移除该文件夹?",
"removeFolderConfirmDescription": "从工作区移除 \"{name}\"?其相关 Tab 与终端将会关闭。", "removeFolderConfirmDescription": "从工作区移除 \"{name}\"?其相关 Tab 与终端将会关闭。",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "会话管理…",
"removeFromWorkspace": "从工作区移除" "removeFromWorkspace": "从工作区移除"
},
"manageConversations": {
"title": "会话管理 — {name}",
"searchPlaceholder": "按标题搜索…",
"agentFilterAll": "全部智能体",
"statusFilterAll": "全部状态",
"selectAllVisible": "全选",
"deselectAll": "取消全选",
"selectedCount": "已选 {count} 条",
"matchedCount": "匹配 {count} 条",
"messagesShort": "{count} 条",
"untitledConversation": "未命名会话",
"setStatus": "设为状态…",
"deleteSelected": "删除",
"noConversations": "此文件夹暂无会话。",
"noMatchingConversations": "没有匹配过滤条件的会话。",
"confirmDeleteTitle": "删除 {count} 个会话?",
"confirmDeleteDescription": "此操作不可撤销。",
"toastDeleted": "已删除 {count} 个会话",
"toastStatusUpdated": "已更新 {count} 个会话的状态",
"toastOpFailed": "操作失败:{message}"
} }
}, },
"conversation": { "conversation": {

View File

@@ -796,7 +796,29 @@
"removeFolderConfirmTitle": "從工作區移除此資料夾?", "removeFolderConfirmTitle": "從工作區移除此資料夾?",
"removeFolderConfirmDescription": "從工作區移除 \"{name}\"?相關分頁與終端機將會關閉。", "removeFolderConfirmDescription": "從工作區移除 \"{name}\"?相關分頁與終端機將會關閉。",
"folderHeaderMenu": { "folderHeaderMenu": {
"manageConversations": "會話管理…",
"removeFromWorkspace": "從工作區移除" "removeFromWorkspace": "從工作區移除"
},
"manageConversations": {
"title": "會話管理 — {name}",
"searchPlaceholder": "依標題搜尋…",
"agentFilterAll": "全部智能體",
"statusFilterAll": "全部狀態",
"selectAllVisible": "全選",
"deselectAll": "取消全選",
"selectedCount": "已選 {count} 筆",
"matchedCount": "符合 {count} 筆",
"messagesShort": "{count} 則",
"untitledConversation": "未命名會話",
"setStatus": "設為狀態…",
"deleteSelected": "刪除",
"noConversations": "此資料夾暫無會話。",
"noMatchingConversations": "沒有符合過濾條件的會話。",
"confirmDeleteTitle": "刪除 {count} 個會話?",
"confirmDeleteDescription": "此操作無法復原。",
"toastDeleted": "已刪除 {count} 個會話",
"toastStatusUpdated": "已更新 {count} 個會話的狀態",
"toastOpFailed": "操作失敗:{message}"
} }
}, },
"conversation": { "conversation": {