feat: implement Story 1.4 — recent view and drag-and-drop organization
Add sortOrder column to diagrams, extend PATCH endpoint with projectId and sortOrder fields, add POST /diagrams/reorder bulk endpoint with ownership verification and duplicate ID validation. Enhance RecentList with lastAiMessage preview subtitle. Implement @dnd-kit drag-and-drop in ProjectTree sidebar with cross-project moves, intra-project reorder, optimistic updates, drag overlay, drop indicators, and keyboard/pointer sensor support. Context-aware GET ordering: sortOrder for project views, updatedAt for recent/all views. 141 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,9 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "2.0.86",
|
||||
"@anaralabs/lector": "3.7.3",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formatjs/intl-localematcher": "0.6.2",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@next/bundle-analyzer": "16.0.10",
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
import { toast } from "sonner";
|
||||
@@ -12,18 +30,86 @@ import { diagramTypeConfig } from "../DiagramCard";
|
||||
import { ProjectContextMenu } from "./ProjectContextMenu";
|
||||
|
||||
import type { DiagramResponse } from "../DiagramCard";
|
||||
import type { DragStartEvent, DragEndEvent, DragOverEvent } from "@dnd-kit/core";
|
||||
|
||||
interface ProjectTreeProps {
|
||||
selectedProjectId: string | null;
|
||||
onSelectProject: (projectId: string | null) => void;
|
||||
}
|
||||
|
||||
function SortableDiagramItem({
|
||||
diagram: d,
|
||||
onClick,
|
||||
}: {
|
||||
diagram: DiagramResponse;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const config = diagramTypeConfig[d.type];
|
||||
const TypeIcon = config.icon;
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({
|
||||
id: d.id,
|
||||
data: { type: "diagram", diagram: d, sourceProjectId: d.projectId },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-accent/50 cursor-grab active:cursor-grabbing"
|
||||
onClick={onClick}
|
||||
>
|
||||
<TypeIcon className={`h-3 w-3 shrink-0 ${config.color}`} />
|
||||
<span className="truncate">{d.title}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DroppableProject({
|
||||
projectId,
|
||||
isOver,
|
||||
children,
|
||||
}: {
|
||||
projectId: string | null;
|
||||
isOver: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: `project-${projectId ?? "unorganized"}`,
|
||||
data: { type: "project", projectId },
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={isOver ? "rounded-md ring-2 ring-primary/50" : ""}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectTree({ selectedProjectId, onSelectProject }: ProjectTreeProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameName, setRenameName] = useState("");
|
||||
const [activeDiagram, setActiveDiagram] = useState<DiagramResponse | null>(null);
|
||||
const [overProjectId, setOverProjectId] = useState<string | null | undefined>(undefined);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const { data: projectsData } = useQuery({
|
||||
queryKey: ["projects"],
|
||||
@@ -41,6 +127,39 @@ export function ProjectTree({ selectedProjectId, onSelectProject }: ProjectTreeP
|
||||
},
|
||||
});
|
||||
|
||||
const moveDiagramMutation = useMutation({
|
||||
mutationFn: async ({ id, projectId }: { id: string; projectId: string | null }) => {
|
||||
const res = await api.diagrams[":id"].$patch({
|
||||
param: { id },
|
||||
json: { projectId },
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to move diagram");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to move diagram");
|
||||
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
|
||||
},
|
||||
});
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: async (items: { id: string; sortOrder: number }[]) => {
|
||||
const res = await api.diagrams.reorder.$post({ json: { items } });
|
||||
if (!res.ok) throw new Error("Failed to reorder");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to reorder diagrams");
|
||||
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
|
||||
},
|
||||
});
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async ({ id, name }: { id: string; name: string }) => {
|
||||
const res = await api.projects[":id"].$patch({
|
||||
@@ -63,7 +182,7 @@ export function ProjectTree({ selectedProjectId, onSelectProject }: ProjectTreeP
|
||||
const diagrams = (allDiagrams?.data ?? []) as DiagramResponse[];
|
||||
|
||||
const toggleExpand = (projectId: string) => {
|
||||
setExpandedProjects(prev => {
|
||||
setExpandedProjects((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(projectId)) next.delete(projectId);
|
||||
else next.add(projectId);
|
||||
@@ -72,9 +191,9 @@ export function ProjectTree({ selectedProjectId, onSelectProject }: ProjectTreeP
|
||||
};
|
||||
|
||||
const getDiagramsForProject = (projectId: string) =>
|
||||
diagrams.filter(d => d.projectId === projectId);
|
||||
diagrams.filter((d) => d.projectId === projectId);
|
||||
|
||||
const unorganizedCount = diagrams.filter(d => !d.projectId).length;
|
||||
const unorganizedDiagrams = diagrams.filter((d) => !d.projectId);
|
||||
|
||||
const handleRename = (projectId: string, originalName: string) => {
|
||||
if (!renameName.trim() || renameName.trim() === originalName) {
|
||||
@@ -89,115 +208,261 @@ export function ProjectTree({ selectedProjectId, onSelectProject }: ProjectTreeP
|
||||
isActive ? "bg-accent text-accent-foreground" : ""
|
||||
}`;
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
const data = event.active.data.current;
|
||||
if (data?.type === "diagram") {
|
||||
setActiveDiagram(data.diagram as DiagramResponse);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
const over = event.over;
|
||||
if (!over) {
|
||||
setOverProjectId(undefined);
|
||||
return;
|
||||
}
|
||||
const overData = over.data.current;
|
||||
if (overData?.type === "project") {
|
||||
setOverProjectId(overData.projectId as string | null);
|
||||
} else if (overData?.type === "diagram") {
|
||||
setOverProjectId(
|
||||
(overData.diagram as DiagramResponse).projectId ?? undefined,
|
||||
);
|
||||
} else {
|
||||
setOverProjectId(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveDiagram(null);
|
||||
setOverProjectId(undefined);
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over || !active.data.current) return;
|
||||
|
||||
const activeData = active.data.current;
|
||||
const overData = over.data.current;
|
||||
|
||||
if (activeData.type !== "diagram") return;
|
||||
|
||||
const draggedDiagram = activeData.diagram as DiagramResponse;
|
||||
const sourceProjectId = activeData.sourceProjectId as string | null;
|
||||
|
||||
// Cross-project drag: dropped on a project droppable
|
||||
if (overData?.type === "project") {
|
||||
const targetProjectId = overData.projectId as string | null;
|
||||
if (sourceProjectId !== targetProjectId) {
|
||||
// Optimistic update
|
||||
queryClient.setQueryData(["diagrams"], (old: { data: DiagramResponse[] } | undefined) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
data: old.data.map((d) =>
|
||||
d.id === draggedDiagram.id ? { ...d, projectId: targetProjectId } : d,
|
||||
),
|
||||
};
|
||||
});
|
||||
moveDiagramMutation.mutate({ id: draggedDiagram.id, projectId: targetProjectId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Intra-project reorder: dropped on another diagram in same project
|
||||
if (overData?.type === "diagram") {
|
||||
const overDiagram = overData.diagram as DiagramResponse;
|
||||
const targetProjectId = overDiagram.projectId;
|
||||
|
||||
if (sourceProjectId !== targetProjectId) {
|
||||
// Cross-project via dropping on diagram item
|
||||
queryClient.setQueryData(["diagrams"], (old: { data: DiagramResponse[] } | undefined) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
data: old.data.map((d) =>
|
||||
d.id === draggedDiagram.id ? { ...d, projectId: targetProjectId } : d,
|
||||
),
|
||||
};
|
||||
});
|
||||
moveDiagramMutation.mutate({ id: draggedDiagram.id, projectId: targetProjectId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Same project reorder
|
||||
const projectDiagrams = diagrams.filter(
|
||||
(d) => d.projectId === sourceProjectId,
|
||||
);
|
||||
const oldIndex = projectDiagrams.findIndex((d) => d.id === active.id);
|
||||
const newIndex = projectDiagrams.findIndex((d) => d.id === over.id);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
const reordered = arrayMove(projectDiagrams, oldIndex, newIndex);
|
||||
const items = reordered.map((d, i) => ({ id: d.id, sortOrder: i }));
|
||||
|
||||
// Optimistic update
|
||||
queryClient.setQueryData(["diagrams"], (old: { data: DiagramResponse[] } | undefined) => {
|
||||
if (!old) return old;
|
||||
const sortMap = new Map(items.map((item) => [item.id, item.sortOrder]));
|
||||
return {
|
||||
...old,
|
||||
data: old.data.map((d) =>
|
||||
sortMap.has(d.id) ? { ...d, sortOrder: sortMap.get(d.id)! } : d,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
reorderMutation.mutate(items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dragOverlayDiagram = activeDiagram;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* All Diagrams */}
|
||||
<button
|
||||
className={itemClass(selectedProjectId === null)}
|
||||
onClick={() => onSelectProject(null)}
|
||||
>
|
||||
<Icons.LayoutDashboard className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">All Diagrams</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{diagrams.length}</span>
|
||||
</button>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{/* All Diagrams */}
|
||||
<button
|
||||
className={itemClass(selectedProjectId === null)}
|
||||
onClick={() => onSelectProject(null)}
|
||||
>
|
||||
<Icons.LayoutDashboard className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">All Diagrams</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{diagrams.length}</span>
|
||||
</button>
|
||||
|
||||
{/* Unorganized */}
|
||||
<button
|
||||
className={itemClass(selectedProjectId === "unorganized")}
|
||||
onClick={() => onSelectProject("unorganized")}
|
||||
>
|
||||
<Icons.Inbox className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Unorganized</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{unorganizedCount}</span>
|
||||
</button>
|
||||
{/* Unorganized — droppable */}
|
||||
<DroppableProject
|
||||
projectId={null}
|
||||
isOver={overProjectId === null}
|
||||
>
|
||||
<button
|
||||
className={itemClass(selectedProjectId === "unorganized")}
|
||||
onClick={() => onSelectProject("unorganized")}
|
||||
>
|
||||
<Icons.Inbox className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Unorganized</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{unorganizedDiagrams.length}
|
||||
</span>
|
||||
</button>
|
||||
</DroppableProject>
|
||||
|
||||
{/* Separator */}
|
||||
{projects.length > 0 && <div className="my-2 border-t" />}
|
||||
{/* Separator */}
|
||||
{projects.length > 0 && <div className="my-2 border-t" />}
|
||||
|
||||
{/* Projects */}
|
||||
{projects.map((proj) => {
|
||||
const projectDiagrams = getDiagramsForProject(proj.id);
|
||||
const isExpanded = expandedProjects.has(proj.id);
|
||||
const isActive = selectedProjectId === proj.id;
|
||||
const isRenaming = renamingId === proj.id;
|
||||
{/* Projects */}
|
||||
{projects.map((proj) => {
|
||||
const projectDiagrams = getDiagramsForProject(proj.id);
|
||||
const isExpanded = expandedProjects.has(proj.id);
|
||||
const isActive = selectedProjectId === proj.id;
|
||||
const isRenaming = renamingId === proj.id;
|
||||
|
||||
return (
|
||||
<div key={proj.id}>
|
||||
<div className="group flex items-center">
|
||||
<button
|
||||
className="p-0.5 hover:bg-accent rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(proj.id);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<Icons.ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<Icons.ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isRenaming ? (
|
||||
<div className="flex-1 px-1">
|
||||
<Input
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onBlur={() => handleRename(proj.id, proj.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename(proj.id, proj.name);
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
return (
|
||||
<DroppableProject
|
||||
key={proj.id}
|
||||
projectId={proj.id}
|
||||
isOver={overProjectId === proj.id}
|
||||
>
|
||||
<div className="group flex items-center">
|
||||
<button
|
||||
className={`${itemClass(isActive)} flex-1`}
|
||||
onClick={() => onSelectProject(proj.id)}
|
||||
>
|
||||
<Icons.FolderOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{proj.name}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{projectDiagrams.length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isRenaming && (
|
||||
<ProjectContextMenu
|
||||
project={proj}
|
||||
onStartRename={() => {
|
||||
setRenameName(proj.name);
|
||||
setRenamingId(proj.id);
|
||||
className="p-0.5 hover:bg-accent rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(proj.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
{isExpanded ? (
|
||||
<Icons.ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<Icons.ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded diagram list */}
|
||||
{isExpanded && projectDiagrams.length > 0 && (
|
||||
<div className="ml-6 space-y-0.5">
|
||||
{projectDiagrams.map((d) => {
|
||||
const config = diagramTypeConfig[d.type];
|
||||
const TypeIcon = config.icon;
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => router.push(pathsConfig.dashboard.user.diagram(d.id))}
|
||||
>
|
||||
<TypeIcon className={`h-3 w-3 shrink-0 ${config.color}`} />
|
||||
<span className="truncate">{d.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{isRenaming ? (
|
||||
<div className="flex-1 px-1">
|
||||
<Input
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onBlur={() => handleRename(proj.id, proj.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename(proj.id, proj.name);
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={`${itemClass(isActive)} flex-1`}
|
||||
onClick={() => onSelectProject(proj.id)}
|
||||
>
|
||||
<Icons.FolderOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{proj.name}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{projectDiagrams.length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isRenaming && (
|
||||
<ProjectContextMenu
|
||||
project={proj}
|
||||
onStartRename={() => {
|
||||
setRenameName(proj.name);
|
||||
setRenamingId(proj.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded diagram list with sortable */}
|
||||
{isExpanded && projectDiagrams.length > 0 && (
|
||||
<div className="ml-6 space-y-0.5">
|
||||
<SortableContext
|
||||
items={projectDiagrams.map((d) => d.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{projectDiagrams.map((d) => (
|
||||
<SortableDiagramItem
|
||||
key={d.id}
|
||||
diagram={d}
|
||||
onClick={() =>
|
||||
router.push(pathsConfig.dashboard.user.diagram(d.id))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
</DroppableProject>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Drag overlay */}
|
||||
<DragOverlay>
|
||||
{dragOverlayDiagram ? (
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/90 px-2 py-1 text-xs shadow-md ring-1 ring-border">
|
||||
{(() => {
|
||||
const config = diagramTypeConfig[dragOverlayDiagram.type];
|
||||
const TypeIcon = config.icon;
|
||||
return (
|
||||
<>
|
||||
<TypeIcon className={`h-3 w-3 shrink-0 ${config.color}`} />
|
||||
<span className="truncate">{dragOverlayDiagram.title}</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,12 +46,21 @@ export function RecentList() {
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent/50 cursor-pointer"
|
||||
className="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => router.push(pathsConfig.dashboard.user.diagram(d.id))}
|
||||
>
|
||||
<TypeIcon className={`h-4 w-4 shrink-0 ${config.color}`} />
|
||||
<span className="flex-1 truncate text-left">{d.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
<TypeIcon className={`h-4 w-4 shrink-0 mt-0.5 ${config.color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block truncate text-left">{d.title}</span>
|
||||
{d.lastAiMessage && (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{d.lastAiMessage.length > 60
|
||||
? `${d.lastAiMessage.slice(0, 60)}...`
|
||||
: d.lastAiMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground mt-0.5">
|
||||
{d.updatedAt ? timeAgo(new Date(d.updatedAt)) : "just now"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user