feat(sidebar): add conversation management dialog with filtering and bulk actions
This commit is contained in:
500
src/components/conversations/conversation-manage-dialog.tsx
Normal file
500
src/components/conversations/conversation-manage-dialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
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 { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
@@ -31,6 +38,7 @@ import {
|
||||
saveFolderExpanded,
|
||||
} from "@/lib/sidebar-view-mode-storage"
|
||||
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
||||
import { ConversationManageDialog } from "./conversation-manage-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
@@ -112,6 +120,7 @@ const FolderHeader = memo(function FolderHeader({
|
||||
onRemoveFromWorkspace,
|
||||
onNewConversation,
|
||||
onImport,
|
||||
onManageConversations,
|
||||
t,
|
||||
}: {
|
||||
folderId: number
|
||||
@@ -123,6 +132,7 @@ const FolderHeader = memo(function FolderHeader({
|
||||
onRemoveFromWorkspace: (folderId: number) => void
|
||||
onNewConversation: (folderId: number) => void
|
||||
onImport: (folderId: number) => void
|
||||
onManageConversations: (folderId: number) => void
|
||||
t: ReturnType<typeof useTranslations>
|
||||
}) {
|
||||
return (
|
||||
@@ -216,6 +226,11 @@ const FolderHeader = memo(function FolderHeader({
|
||||
{importing ? t("importing") : t("importLocalSessions")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => onManageConversations(folderId)}>
|
||||
<ListChecks className="h-4 w-4" />
|
||||
{t("folderHeaderMenu.manageConversations")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onRemoveFromWorkspace(folderId)}
|
||||
@@ -301,6 +316,10 @@ export function SidebarConversationList({
|
||||
folderId: number
|
||||
folderName: string
|
||||
} | null>(null)
|
||||
const [manageState, setManageState] = useState<{
|
||||
folderId: number
|
||||
folderName: string
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
||||
@@ -483,6 +502,14 @@ export function SidebarConversationList({
|
||||
[folderIndex]
|
||||
)
|
||||
|
||||
const handleManageConversations = useCallback(
|
||||
(folderId: number) => {
|
||||
const name = folderIndex.get(folderId)?.name ?? String(folderId)
|
||||
setManageState({ folderId, folderName: name })
|
||||
},
|
||||
[folderIndex]
|
||||
)
|
||||
|
||||
const handleRemoveFolderConfirm = useCallback(async () => {
|
||||
if (!removeConfirm) return
|
||||
const { folderId, folderName } = removeConfirm
|
||||
@@ -705,6 +732,7 @@ export function SidebarConversationList({
|
||||
onRemoveFromWorkspace={handleRemoveFolder}
|
||||
onNewConversation={handleNewConversationForFolder}
|
||||
onImport={handleImportForFolder}
|
||||
onManageConversations={handleManageConversations}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
@@ -735,6 +763,7 @@ export function SidebarConversationList({
|
||||
onRemoveFromWorkspace={handleRemoveFolder}
|
||||
onNewConversation={handleNewConversationForFolder}
|
||||
onImport={handleImportForFolder}
|
||||
onManageConversations={handleManageConversations}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
@@ -795,6 +824,15 @@ export function SidebarConversationList({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{manageState && (
|
||||
<ConversationManageDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && setManageState(null)}
|
||||
folderId={manageState.folderId}
|
||||
folderName={manageState.folderName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -796,7 +796,29 @@
|
||||
"removeFolderConfirmTitle": "إزالة المجلد من مساحة العمل؟",
|
||||
"removeFolderConfirmDescription": "إزالة \"{name}\" من مساحة العمل؟ سيتم إغلاق علامات التبويب والمحطات المرتبطة.",
|
||||
"folderHeaderMenu": {
|
||||
"manageConversations": "إدارة المحادثات…",
|
||||
"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": {
|
||||
|
||||
@@ -796,7 +796,29 @@
|
||||
"removeFolderConfirmTitle": "Ordner aus Arbeitsbereich entfernen?",
|
||||
"removeFolderConfirmDescription": "\"{name}\" aus dem Arbeitsbereich entfernen? Zugehörige Tabs und Terminals werden geschlossen.",
|
||||
"folderHeaderMenu": {
|
||||
"manageConversations": "Konversationen verwalten…",
|
||||
"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": {
|
||||
|
||||
@@ -796,7 +796,29 @@
|
||||
"removeFolderConfirmTitle": "Remove folder from workspace?",
|
||||
"removeFolderConfirmDescription": "Remove \"{name}\" from the workspace? Its tabs and terminals will close.",
|
||||
"folderHeaderMenu": {
|
||||
"manageConversations": "Manage conversations…",
|
||||
"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": {
|
||||
|
||||
@@ -796,7 +796,29 @@
|
||||
"removeFolderConfirmTitle": "¿Eliminar carpeta del espacio de trabajo?",
|
||||
"removeFolderConfirmDescription": "¿Eliminar \"{name}\" del espacio de trabajo? Sus pestañas y terminales se cerrarán.",
|
||||
"folderHeaderMenu": {
|
||||
"manageConversations": "Gestionar conversaciones…",
|
||||
"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": {
|
||||
|
||||
@@ -796,7 +796,29 @@
|
||||
"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.",
|
||||
"folderHeaderMenu": {
|
||||
"manageConversations": "Gérer les conversations…",
|
||||
"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": {
|
||||
|
||||
@@ -796,7 +796,29 @@
|
||||
"removeFolderConfirmTitle": "このフォルダをワークスペースから削除しますか?",
|
||||
"removeFolderConfirmDescription": "\"{name}\" をワークスペースから削除しますか?関連するタブとターミナルが閉じられます。",
|
||||
"folderHeaderMenu": {
|
||||
"manageConversations": "会話の管理…",
|
||||
"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": {
|
||||
|
||||
@@ -796,7 +796,29 @@
|
||||
"removeFolderConfirmTitle": "이 폴더를 워크스페이스에서 제거하시겠습니까?",
|
||||
"removeFolderConfirmDescription": "워크스페이스에서 \"{name}\"을(를) 제거하시겠습니까? 관련 탭과 터미널이 닫힙니다.",
|
||||
"folderHeaderMenu": {
|
||||
"manageConversations": "대화 관리…",
|
||||
"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": {
|
||||
|
||||
@@ -796,7 +796,29 @@
|
||||
"removeFolderConfirmTitle": "Remover pasta do espaço de trabalho?",
|
||||
"removeFolderConfirmDescription": "Remover \"{name}\" do espaço de trabalho? As abas e terminais relacionados serão fechados.",
|
||||
"folderHeaderMenu": {
|
||||
"manageConversations": "Gerenciar conversas…",
|
||||
"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": {
|
||||
|
||||
@@ -796,7 +796,29 @@
|
||||
"removeFolderConfirmTitle": "从工作区移除该文件夹?",
|
||||
"removeFolderConfirmDescription": "从工作区移除 \"{name}\"?其相关 Tab 与终端将会关闭。",
|
||||
"folderHeaderMenu": {
|
||||
"manageConversations": "会话管理…",
|
||||
"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": {
|
||||
|
||||
@@ -796,7 +796,29 @@
|
||||
"removeFolderConfirmTitle": "從工作區移除此資料夾?",
|
||||
"removeFolderConfirmDescription": "從工作區移除 \"{name}\"?相關分頁與終端機將會關閉。",
|
||||
"folderHeaderMenu": {
|
||||
"manageConversations": "會話管理…",
|
||||
"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": {
|
||||
|
||||
Reference in New Issue
Block a user