feat(settings): add quick messages management with drag-and-drop sorting

Adds a new "Quick Messages" settings page below Experts for managing reusable title/content snippets, backed by SQLite via SeaORM and exposed through both Tauri commands and the Axum web router. The list supports drag-to-reorder using the same motion/react Reorder pattern as the agent list, with translations provided across all 10 supported locales.
This commit is contained in:
xintaofei
2026-04-24 10:46:33 +08:00
parent fbe272de4f
commit 61778f152b
30 changed files with 1434 additions and 11 deletions

View File

@@ -0,0 +1,583 @@
"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent,
type ReactNode,
} from "react"
import { GripVertical, Loader2, Plus, Save, Trash2 } from "lucide-react"
import { Reorder, useDragControls } from "motion/react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import {
quickMessagesCreate,
quickMessagesDelete,
quickMessagesList,
quickMessagesReorder,
quickMessagesUpdate,
} from "@/lib/api"
import type { QuickMessage } from "@/lib/types"
const LEFT_MIN_WIDTH = 280
const RIGHT_MIN_WIDTH = 420
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
function toPercent(pixels: number, totalPixels: number): number {
if (totalPixels <= 0) return 0
return (pixels / totalPixels) * 100
}
interface QuickMessageReorderItemProps {
message: QuickMessage
selected: boolean
reordering: boolean
onSelect: (id: number) => void
onDragStart: () => void
onDragEnd: () => void
children: (
startDrag: (event: PointerEvent<HTMLButtonElement>) => void
) => ReactNode
}
function QuickMessageReorderItem({
message,
selected,
reordering,
onSelect,
onDragStart,
onDragEnd,
children,
}: QuickMessageReorderItemProps) {
const dragControls = useDragControls()
const startDrag = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
dragControls.start(event)
},
[dragControls]
)
return (
<Reorder.Item
as="section"
value={message}
data-quick-message-id={message.id}
drag={reordering ? false : "y"}
dragListener={false}
dragControls={dragControls}
dragMomentum={false}
layout="position"
className={cn(
"rounded-lg border bg-card p-2.5 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
selected && "border-primary/60 bg-primary/5"
)}
tabIndex={0}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={() => onSelect(message.id)}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
onSelect(message.id)
}}
>
{children(startDrag)}
</Reorder.Item>
)
}
export function QuickMessagesSettings() {
const t = useTranslations("QuickMessagesSettings")
const [messages, setMessages] = useState<QuickMessage[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [selectedId, setSelectedId] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [draftTitle, setDraftTitle] = useState("")
const [draftContent, setDraftContent] = useState("")
const [saving, setSaving] = useState(false)
const [creating, setCreating] = useState(false)
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null)
const [deleting, setDeleting] = useState(false)
const [reordering, setReordering] = useState(false)
const pendingOrderRef = useRef<number[] | null>(null)
const panelContainerRef = useRef<HTMLDivElement | null>(null)
const [panelContainerWidth, setPanelContainerWidth] = useState(0)
const titleInputRef = useRef<HTMLInputElement | null>(null)
const refresh = useCallback(async () => {
setLoading(true)
setLoadError(null)
try {
const list = await quickMessagesList()
setMessages(list)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setLoadError(message)
setMessages([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refresh().catch((err) => {
console.error("[QuickMessagesSettings] initial refresh failed:", err)
})
}, [refresh])
useEffect(() => {
const container = panelContainerRef.current
if (!container) return
const updateWidth = (next: number) => {
setPanelContainerWidth((prev) =>
Math.abs(prev - next) < 1 ? prev : next
)
}
updateWidth(container.getBoundingClientRect().width)
const observer = new ResizeObserver((entries) => {
updateWidth(
entries[0]?.contentRect.width ?? container.getBoundingClientRect().width
)
})
observer.observe(container)
return () => {
observer.disconnect()
}
}, [])
const selectedMessage = useMemo(
() => messages.find((m) => m.id === selectedId) ?? null,
[messages, selectedId]
)
useEffect(() => {
if (selectedMessage) {
setDraftTitle(selectedMessage.title)
setDraftContent(selectedMessage.content)
} else {
setDraftTitle("")
setDraftContent("")
}
}, [selectedMessage])
useEffect(() => {
if (selectedId === null && messages.length > 0) {
setSelectedId(messages[0].id)
}
}, [selectedId, messages])
const filteredMessages = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return messages
return messages.filter(
(m) =>
m.title.toLowerCase().includes(q) || m.content.toLowerCase().includes(q)
)
}, [messages, searchQuery])
const isDirty = useMemo(() => {
if (!selectedMessage) return false
return (
draftTitle !== selectedMessage.title ||
draftContent !== selectedMessage.content
)
}, [selectedMessage, draftTitle, draftContent])
const persistReorder = useCallback(
async (ids: number[]) => {
if (ids.length === 0) return
setReordering(true)
try {
await quickMessagesReorder(ids)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveOrderFailed"), { description: message })
await refresh()
} finally {
setReordering(false)
}
},
[refresh, t]
)
const handleReorder = useCallback((next: QuickMessage[]) => {
const reordered = next.map((m, index) => ({ ...m, sort_order: index }))
setMessages(reordered)
pendingOrderRef.current = reordered.map((m) => m.id)
}, [])
const handleCreate = useCallback(async () => {
setCreating(true)
try {
const created = await quickMessagesCreate({ title: "", content: "" })
setMessages((prev) => [...prev, created])
setSelectedId(created.id)
toast.success(t("toasts.created"))
requestAnimationFrame(() => {
titleInputRef.current?.focus()
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.createFailed"), { description: message })
} finally {
setCreating(false)
}
}, [t])
const handleSave = useCallback(async () => {
if (!selectedMessage) return
setSaving(true)
try {
const updated = await quickMessagesUpdate({
id: selectedMessage.id,
title: draftTitle,
content: draftContent,
})
setMessages((prev) =>
prev.map((m) => (m.id === updated.id ? updated : m))
)
toast.success(t("toasts.saved"))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveFailed"), { description: message })
} finally {
setSaving(false)
}
}, [selectedMessage, draftTitle, draftContent, t])
const handleDelete = useCallback(async () => {
if (deleteTargetId === null) return
const target = deleteTargetId
setDeleting(true)
try {
await quickMessagesDelete(target)
setMessages((prev) => {
const next = prev.filter((m) => m.id !== target)
if (selectedId === target) {
setSelectedId(next[0]?.id ?? null)
}
return next
})
toast.success(t("toasts.deleted"))
setDeleteTargetId(null)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.deleteFailed"), { description: message })
} finally {
setDeleting(false)
}
}, [deleteTargetId, selectedId, t])
const safeContainerWidth =
panelContainerWidth > 0 ? panelContainerWidth : 1200
const leftMinSize = clamp(
toPercent(LEFT_MIN_WIDTH, safeContainerWidth),
5,
95
)
const rightMinSize = clamp(
toPercent(RIGHT_MIN_WIDTH, safeContainerWidth),
5,
95
)
const leftMaxSize = Math.max(leftMinSize, 100 - rightMinSize)
if (loading) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("loading")}
</div>
)
}
const deleteTargetMessage =
deleteTargetId !== null
? (messages.find((m) => m.id === deleteTargetId) ?? null)
: null
return (
<div className="h-full flex flex-col p-3 md:p-4">
<div className="flex items-center justify-between gap-3 pb-4">
<div>
<h2 className="text-base font-semibold">{t("title")}</h2>
<p className="text-xs text-muted-foreground mt-1">
{t("description")}
</p>
</div>
</div>
{loadError && (
<div className="mb-3 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{loadError}
</div>
)}
<div ref={panelContainerRef} className="flex-1 min-h-0 min-w-0">
<ResizablePanelGroup
direction="horizontal"
className="h-full min-h-0 min-w-0"
>
<ResizablePanel
defaultSize={34}
minSize={leftMinSize}
maxSize={leftMaxSize}
>
<div className="min-h-0 h-full min-w-0 rounded-lg border bg-card flex flex-col overflow-hidden lg:rounded-r-none">
<div className="border-b p-3 space-y-2.5">
<div className="flex items-center gap-2">
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("searchPlaceholder")}
/>
<Button
size="sm"
onClick={() => {
handleCreate().catch((err) => {
console.error(
"[QuickMessagesSettings] create failed:",
err
)
})
}}
disabled={creating}
>
{creating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
{t("actions.new")}
</Button>
</div>
</div>
{filteredMessages.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground px-4 text-center">
{messages.length === 0
? t("emptyList")
: t("searchPlaceholder")}
</div>
) : (
<Reorder.Group
as="div"
axis="y"
values={messages}
onReorder={handleReorder}
className="flex-1 min-h-0 overflow-y-auto space-y-2 p-2"
>
{filteredMessages.map((m) => (
<QuickMessageReorderItem
key={m.id}
message={m}
selected={selectedId === m.id}
reordering={reordering}
onSelect={(id) => setSelectedId(id)}
onDragStart={() => {
/* no-op: list re-render handles dragging state */
}}
onDragEnd={() => {
const order = pendingOrderRef.current
pendingOrderRef.current = null
if (order && !reordering) {
persistReorder(order).catch((err) => {
console.error(
"[QuickMessagesSettings] reorder failed:",
err
)
})
}
}}
>
{(startDrag) => (
<div className="flex items-center gap-2 overflow-hidden">
<button
type="button"
className="text-muted-foreground cursor-grab active:cursor-grabbing rounded p-0.5 hover:bg-muted"
title={t("actions.dragSort")}
aria-label={t("actions.dragSortMessage", {
name: m.title || t("untitled"),
})}
onPointerDown={startDrag}
onClick={(event) => event.stopPropagation()}
disabled={reordering}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{m.title || (
<span className="italic text-muted-foreground">
{t("untitled")}
</span>
)}
</div>
{m.content && (
<div className="text-[11px] text-muted-foreground truncate mt-0.5">
{m.content}
</div>
)}
</div>
</div>
)}
</QuickMessageReorderItem>
))}
</Reorder.Group>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={66} minSize={rightMinSize}>
<div className="h-full flex-1 min-h-0 min-w-0 rounded-lg border bg-card overflow-hidden lg:rounded-l-none lg:border-l-0">
{selectedMessage ? (
<div className="h-full flex flex-col">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="quick-message-title" className="text-xs">
{t("fields.title")}
</Label>
<Input
id="quick-message-title"
ref={titleInputRef}
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
placeholder={t("fields.titlePlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor="quick-message-content"
className="text-xs"
>
{t("fields.content")}
</Label>
<Textarea
id="quick-message-content"
value={draftContent}
onChange={(event) =>
setDraftContent(event.target.value)
}
placeholder={t("fields.contentPlaceholder")}
className="min-h-[260px]"
/>
</div>
</div>
<div className="border-t px-4 py-3 flex items-center justify-between gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setDeleteTargetId(selectedMessage.id)}
className="text-red-500 hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
{t("actions.delete")}
</Button>
<Button
size="sm"
onClick={() => {
handleSave().catch((err) => {
console.error(
"[QuickMessagesSettings] save failed:",
err
)
})
}}
disabled={saving || !isDirty}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("actions.save")}
</Button>
</div>
</div>
) : (
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
{t("emptySelection")}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<AlertDialog
open={deleteTargetId !== null}
onOpenChange={(open) => {
if (!open) setDeleteTargetId(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("confirmDelete.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("confirmDelete.message", {
name: deleteTargetMessage?.title || t("untitled"),
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("confirmDelete.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(event) => {
event.preventDefault()
handleDelete().catch((err) => {
console.error("[QuickMessagesSettings] delete failed:", err)
})
}}
disabled={deleting}
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : null}
{t("confirmDelete.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -14,6 +14,7 @@ import {
Globe,
Keyboard,
Menu,
MessageSquareText,
SendHorizontal,
Palette,
PlugZap,
@@ -41,6 +42,7 @@ interface SettingsNavItem {
| "mcp"
| "skills"
| "experts"
| "quick_messages"
| "shortcuts"
| "version_control"
| "chat_channels"
@@ -70,6 +72,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
labelKey: "experts",
icon: Sparkles,
},
{
href: "/settings/quick-messages",
labelKey: "quick_messages",
icon: MessageSquareText,
},
{
href: "/settings/agents",
labelKey: "agents",