mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat(chat): add realtime comment/attachment counters with toggleable task panel
Subscribe to team.task.commented and new team.task.attachment_added events in chat view. Show inline notifications and increment counters on active task cards. Add toggleable sidebar panel (auto-opens on first task dispatch, auto-closes when all tasks complete). Mobile renders as slide-over with backdrop overlay.
This commit is contained in:
1 parent
5f1658cfad
commit
7ad15a375a
10 files changed
+218
-32
No files matched your search
@@ -481,6 +481,19 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any)
|
||||
return ErrorResult("failed to attach file: " + err.Error())
|
||||
}
|
||||
|
||||
t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskAttachmentAdded, protocol.TeamTaskEventPayload{
|
||||
TeamID: team.ID.String(),
|
||||
TaskID: taskID.String(),
|
||||
TaskNumber: task.TaskNumber,
|
||||
Subject: task.Subject,
|
||||
UserID: store.UserIDFromContext(ctx),
|
||||
Channel: ToolChannelFromCtx(ctx),
|
||||
ChatID: chatID,
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
ActorType: "agent",
|
||||
ActorID: t.manager.agentKeyFromID(ctx, agentID),
|
||||
})
|
||||
|
||||
return NewResult(fmt.Sprintf("File attached to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID))
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ const (
|
||||
EventTeamTaskDispatched = "team.task.dispatched"
|
||||
EventTeamTaskUpdated = "team.task.updated"
|
||||
EventTeamTaskDeleted = "team.task.deleted"
|
||||
EventTeamTaskStale = "team.task.stale"
|
||||
EventTeamTaskStale = "team.task.stale"
|
||||
EventTeamTaskAttachmentAdded = "team.task.attachment_added"
|
||||
|
||||
// Emitted when leader starts processing completed team task results (before announce run).
|
||||
EventTeamLeaderProcessing = "team.leader.processing"
|
||||
|
||||
@@ -216,6 +216,7 @@ export const Events = {
|
||||
TEAM_TASK_ASSIGNED: "team.task.assigned",
|
||||
TEAM_TASK_DISPATCHED: "team.task.dispatched",
|
||||
TEAM_TASK_DELETED: "team.task.deleted",
|
||||
TEAM_TASK_ATTACHMENT_ADDED: "team.task.attachment_added",
|
||||
|
||||
// Team leader processing (bridges gap between last task.completed and announce run.started)
|
||||
TEAM_LEADER_PROCESSING: "team.leader.processing",
|
||||
@@ -261,7 +262,7 @@ export const TEAM_RELATED_EVENTS: Set<string> = new Set([
|
||||
Events.TEAM_TASK_COMPLETED, Events.TEAM_TASK_CANCELLED,
|
||||
Events.TEAM_TASK_REVIEWED, Events.TEAM_TASK_APPROVED,
|
||||
Events.TEAM_TASK_REJECTED, Events.TEAM_TASK_PROGRESS,
|
||||
Events.TEAM_TASK_COMMENTED, Events.TEAM_TASK_ASSIGNED, Events.TEAM_TASK_DISPATCHED, Events.TEAM_TASK_DELETED,
|
||||
Events.TEAM_TASK_COMMENTED, Events.TEAM_TASK_ASSIGNED, Events.TEAM_TASK_DISPATCHED, Events.TEAM_TASK_DELETED, Events.TEAM_TASK_ATTACHMENT_ADDED,
|
||||
Events.TEAM_MESSAGE_SENT,
|
||||
Events.TEAM_CREATED, Events.TEAM_UPDATED, Events.TEAM_DELETED,
|
||||
Events.TEAM_MEMBER_ADDED, Events.TEAM_MEMBER_REMOVED,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Loader2, Bot, Users } from "lucide-react";
|
||||
import { Loader2, Bot, Users, PanelRightOpen, PanelRightClose } from "lucide-react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import type { RunActivity, ActiveTeamTask } from "@/types/chat";
|
||||
@@ -11,6 +11,8 @@ interface ChatTopBarProps {
|
||||
isBusy: boolean;
|
||||
activity: RunActivity | null;
|
||||
teamTasks: ActiveTeamTask[];
|
||||
onToggleTaskPanel?: () => void;
|
||||
taskPanelOpen?: boolean;
|
||||
}
|
||||
|
||||
const phaseLabels: Record<RunActivity["phase"], string> = {
|
||||
@@ -22,7 +24,7 @@ const phaseLabels: Record<RunActivity["phase"], string> = {
|
||||
leader_processing: "Processing team results…",
|
||||
};
|
||||
|
||||
export function ChatTopBar({ agentId, isRunning, isBusy, activity, teamTasks }: ChatTopBarProps) {
|
||||
export function ChatTopBar({ agentId, isRunning, isBusy, activity, teamTasks, onToggleTaskPanel, taskPanelOpen }: ChatTopBarProps) {
|
||||
const http = useHttp();
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
const [agent, setAgent] = useState<{ name: string; emoji?: string } | null>(null);
|
||||
@@ -47,6 +49,7 @@ export function ChatTopBar({ agentId, isRunning, isBusy, activity, teamTasks }:
|
||||
|
||||
const displayName = agent?.name ?? agentId;
|
||||
const emoji = agent?.emoji;
|
||||
const PanelIcon = taskPanelOpen ? PanelRightClose : PanelRightOpen;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b px-4 py-1.5">
|
||||
@@ -59,20 +62,38 @@ export function ChatTopBar({ agentId, isRunning, isBusy, activity, teamTasks }:
|
||||
<span className="text-sm font-semibold">{displayName}</span>
|
||||
</div>
|
||||
|
||||
{isRunning ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{activity ? phaseLabels[activity.phase] : "Running…"}</span>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</div>
|
||||
) : isBusy ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span>Team: {teamTasks.length} task{teamTasks.length > 1 ? "s" : ""} active</span>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/50">Ready</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{isRunning ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{activity ? phaseLabels[activity.phase] : "Running…"}</span>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</div>
|
||||
) : isBusy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTaskPanel}
|
||||
className="flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span>Team: {teamTasks.length} task{teamTasks.length > 1 ? "s" : ""} active</span>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/50">Ready</span>
|
||||
)}
|
||||
|
||||
{/* Task panel toggle — visible when there are (or recently were) team tasks */}
|
||||
{teamTasks.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTaskPanel}
|
||||
className="relative rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title={taskPanelOpen ? "Close task panel" : "Open task panel"}
|
||||
>
|
||||
<PanelIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { X, MessageSquare, Paperclip } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ActiveTeamTask } from "@/types/chat";
|
||||
|
||||
interface TaskPanelProps {
|
||||
tasks: ActiveTeamTask[];
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TaskPanel({ tasks, open, onClose }: TaskPanelProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex h-full w-72 shrink-0 flex-col border-l bg-background",
|
||||
"max-sm:fixed max-sm:inset-y-0 max-sm:right-0 max-sm:z-50 max-sm:w-full max-sm:max-w-[85vw] max-sm:shadow-xl",
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">
|
||||
Tasks ({tasks.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-2 space-y-1.5">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">No active tasks</p>
|
||||
) : (
|
||||
tasks.map((task) => <TaskCard key={task.taskId} task={task} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskCard({ task }: { task: ActiveTeamTask }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-card p-2 text-xs">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="shrink-0 font-mono text-muted-foreground">#{task.taskNumber}</span>
|
||||
<span className="font-medium leading-tight line-clamp-2">{task.subject}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-2 text-muted-foreground">
|
||||
<span className="truncate">{task.ownerDisplayName || task.ownerAgentKey || "unassigned"}</span>
|
||||
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
{(task.commentCount ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<MessageSquare className="h-3 w-3" /> {task.commentCount}
|
||||
</span>
|
||||
)}
|
||||
{(task.attachmentCount ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Paperclip className="h-3 w-3" /> {task.attachmentCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{task.progressPercent != null && (
|
||||
<div className="mt-1.5">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span className="truncate">{task.progressStep || "In progress"}</span>
|
||||
<span className="shrink-0 ml-1">{task.progressPercent}%</span>
|
||||
</div>
|
||||
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.min(task.progressPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,50 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { Users, MessageSquare, Paperclip } from "lucide-react";
|
||||
import type { ActiveTeamTask } from "@/types/chat";
|
||||
|
||||
interface TeamActivityPanelProps {
|
||||
tasks: ActiveTeamTask[];
|
||||
onTogglePanel?: () => void;
|
||||
}
|
||||
|
||||
export function TeamActivityPanel({ tasks }: TeamActivityPanelProps) {
|
||||
export function TeamActivityPanel({ tasks, onTogglePanel }: TeamActivityPanelProps) {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted px-3 py-2">
|
||||
<div className="mb-1.5 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="mb-1.5 flex w-full items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={onTogglePanel}
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
Team: {tasks.length} task{tasks.length > 1 ? "s" : ""} active
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="space-y-1">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.taskId} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">#{task.taskNumber}</span>
|
||||
<span className="truncate font-medium">{task.subject}</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{task.ownerDisplayName || task.ownerAgentKey}
|
||||
</span>
|
||||
{task.progressPercent != null && (
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">
|
||||
{task.progressPercent}%
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2 text-muted-foreground">
|
||||
{(task.commentCount ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<MessageSquare className="h-3 w-3" /> {task.commentCount}
|
||||
</span>
|
||||
)}
|
||||
{(task.attachmentCount ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Paperclip className="h-3 w-3" /> {task.attachmentCount}
|
||||
</span>
|
||||
)}
|
||||
{task.progressPercent != null && (
|
||||
<span>{task.progressPercent}%</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useChatMessages } from "./hooks/use-chat-messages";
|
||||
import { useChatSend } from "./hooks/use-chat-send";
|
||||
import { isOwnSession, parseSessionKey } from "@/lib/session-key";
|
||||
import { useVirtualKeyboard } from "@/hooks/use-virtual-keyboard";
|
||||
import { TaskPanel } from "@/components/chat/task-panel";
|
||||
|
||||
export function ChatPage() {
|
||||
const { t } = useTranslation("chat");
|
||||
@@ -147,6 +148,17 @@ export function ChatPage() {
|
||||
const isMobile = useIsMobile();
|
||||
useVirtualKeyboard();
|
||||
const [chatSidebarOpen, setChatSidebarOpen] = useState(false);
|
||||
const [taskPanelOpen, setTaskPanelOpen] = useState(false);
|
||||
|
||||
// Auto-open task panel when first task appears, auto-close when all done.
|
||||
const prevTaskCountRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const prev = prevTaskCountRef.current;
|
||||
const curr = teamTasks.length;
|
||||
if (prev === 0 && curr > 0) setTaskPanelOpen(true);
|
||||
if (curr === 0 && prev > 0) setTaskPanelOpen(false);
|
||||
prevTaskCountRef.current = curr;
|
||||
}, [teamTasks.length]);
|
||||
|
||||
const handleSessionSelectMobile = useCallback(
|
||||
(key: string) => {
|
||||
@@ -218,7 +230,7 @@ export function ChatPage() {
|
||||
)}
|
||||
|
||||
<div className="shrink-0">
|
||||
<ChatTopBar agentId={agentId} isRunning={isRunning} isBusy={isBusy} activity={activity} teamTasks={teamTasks} />
|
||||
<ChatTopBar agentId={agentId} isRunning={isRunning} isBusy={isBusy} activity={activity} teamTasks={teamTasks} onToggleTaskPanel={() => setTaskPanelOpen((v) => !v)} taskPanelOpen={taskPanelOpen} />
|
||||
</div>
|
||||
|
||||
{sendError && (
|
||||
@@ -240,6 +252,7 @@ export function ChatPage() {
|
||||
isBusy={isBusy}
|
||||
loading={messagesLoading}
|
||||
scrollTrigger={scrollTrigger}
|
||||
onToggleTaskPanel={() => setTaskPanelOpen((v) => !v)}
|
||||
/>
|
||||
|
||||
{isOwn ? (
|
||||
@@ -259,6 +272,14 @@ export function ChatPage() {
|
||||
)}
|
||||
</DropZone>
|
||||
</div>
|
||||
|
||||
{/* Mobile overlay backdrop — must render before TaskPanel so panel sits above */}
|
||||
{isMobile && taskPanelOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-black/50" onClick={() => setTaskPanelOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Task panel — toggleable sidebar on the right */}
|
||||
<TaskPanel tasks={teamTasks} open={taskPanelOpen} onClose={() => setTaskPanelOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ interface ChatThreadProps {
|
||||
isBusy: boolean;
|
||||
loading?: boolean;
|
||||
scrollTrigger?: number;
|
||||
onToggleTaskPanel?: () => void;
|
||||
}
|
||||
|
||||
/** Check if a message is tool-only (no user-visible text content) */
|
||||
@@ -73,7 +74,7 @@ function buildDisplayItems(messages: ChatMessage[]): DisplayItem[] {
|
||||
|
||||
export const ChatThread = memo(function ChatThread({
|
||||
messages, streamText, thinkingText, toolStream, blockReplies,
|
||||
activity, teamTasks, isRunning, isBusy, loading, scrollTrigger = 0,
|
||||
activity, teamTasks, isRunning, isBusy, loading, scrollTrigger = 0, onToggleTaskPanel,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const { ref, onScroll } = useAutoScroll<HTMLDivElement>(
|
||||
@@ -122,7 +123,7 @@ export const ChatThread = memo(function ChatThread({
|
||||
}
|
||||
})}
|
||||
|
||||
{teamTasks.length > 0 && <TeamActivityPanel tasks={teamTasks} />}
|
||||
{teamTasks.length > 0 && <TeamActivityPanel tasks={teamTasks} onTogglePanel={onToggleTaskPanel} />}
|
||||
|
||||
<ActiveRunZone
|
||||
isRunning={isRunning}
|
||||
|
||||
@@ -412,6 +412,18 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
return prev.filter((t) => t.taskId !== event.task_id);
|
||||
}
|
||||
|
||||
// Increment counters for comment/attachment events
|
||||
if (eventName === "team.task.commented" && existing) {
|
||||
return prev.map((t) =>
|
||||
t.taskId === event.task_id ? { ...t, commentCount: (t.commentCount ?? 0) + 1 } : t,
|
||||
);
|
||||
}
|
||||
if (eventName === "team.task.attachment_added" && existing) {
|
||||
return prev.map((t) =>
|
||||
t.taskId === event.task_id ? { ...t, attachmentCount: (t.attachmentCount ?? 0) + 1 } : t,
|
||||
);
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
|
||||
@@ -419,7 +431,9 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
if (
|
||||
eventName === "team.task.dispatched" ||
|
||||
eventName === "team.task.completed" ||
|
||||
eventName === "team.task.failed"
|
||||
eventName === "team.task.failed" ||
|
||||
eventName === "team.task.commented" ||
|
||||
eventName === "team.task.attachment_added"
|
||||
) {
|
||||
let icon = "📋";
|
||||
let text = "";
|
||||
@@ -429,6 +443,12 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
} else if (eventName === "team.task.failed") {
|
||||
icon = "❌";
|
||||
text = `Task #${event.task_number} "${event.subject}" failed${event.reason ? ": " + event.reason : ""}`;
|
||||
} else if (eventName === "team.task.commented") {
|
||||
icon = "💬";
|
||||
text = `Comment on #${event.task_number} "${event.subject}"`;
|
||||
} else if (eventName === "team.task.attachment_added") {
|
||||
icon = "📎";
|
||||
text = `File attached to #${event.task_number} "${event.subject}"`;
|
||||
} else {
|
||||
icon = "📋";
|
||||
text = `Task #${event.task_number} "${event.subject}" → ${event.owner_display_name || event.owner_agent_key}`;
|
||||
@@ -456,6 +476,8 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
const onTaskCancelled = useMemo(() => handleTeamTaskEvent("team.task.cancelled"), [handleTeamTaskEvent]);
|
||||
const onTaskProgress = useMemo(() => handleTeamTaskEvent("team.task.progress"), [handleTeamTaskEvent]);
|
||||
const onTaskAssigned = useMemo(() => handleTeamTaskEvent("team.task.assigned"), [handleTeamTaskEvent]);
|
||||
const onTaskCommented = useMemo(() => handleTeamTaskEvent("team.task.commented"), [handleTeamTaskEvent]);
|
||||
const onTaskAttached = useMemo(() => handleTeamTaskEvent("team.task.attachment_added"), [handleTeamTaskEvent]);
|
||||
|
||||
useWsEvent(Events.TEAM_TASK_DISPATCHED, onTaskDispatched);
|
||||
useWsEvent(Events.TEAM_TASK_COMPLETED, onTaskCompleted);
|
||||
@@ -463,6 +485,8 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
useWsEvent(Events.TEAM_TASK_CANCELLED, onTaskCancelled);
|
||||
useWsEvent(Events.TEAM_TASK_PROGRESS, onTaskProgress);
|
||||
useWsEvent(Events.TEAM_TASK_ASSIGNED, onTaskAssigned);
|
||||
useWsEvent(Events.TEAM_TASK_COMMENTED, onTaskCommented);
|
||||
useWsEvent(Events.TEAM_TASK_ATTACHMENT_ADDED, onTaskAttached);
|
||||
|
||||
// Leader processing: backend emits when announce queue drains (before announce run starts).
|
||||
const handleLeaderProcessing = useCallback((payload: unknown) => {
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ActiveTeamTask {
|
||||
ownerDisplayName?: string;
|
||||
progressPercent?: number;
|
||||
progressStep?: string;
|
||||
commentCount?: number;
|
||||
attachmentCount?: number;
|
||||
}
|
||||
|
||||
/** Media item for gallery display */
|
||||
|
||||
Reference in new issue
Block a user