mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
feat(ui): integrate chat redesign + file preview modal
- ChatThread: merge consecutive tool-only messages, use ActiveRunZone - ChatPage: add ChatTopBar, DropZone, pass new hook state - MessageBubble: compact tool-only layout, block reply support - ToolCallCard: compact mode with smaller padding - AgentSelector: show emoji from other_config - SessionSwitcher: friendly labels (Chat xxx instead of raw keys) - FileBlock: click opens rich content modal with download button - MarkdownRenderer: intercept /v1/files/ links as file chips with inline download icon, modal preview for md/code/audio/video/image
This commit is contained in:
1 parent
a9ec6302ca
commit
15916823e0
8 files changed
+458
-223
No files matched your search
@@ -11,6 +11,11 @@ interface AgentSelectorProps {
|
||||
onChange: (agentId: string) => void;
|
||||
}
|
||||
|
||||
/** Extract emoji from agent's other_config JSONB */
|
||||
function agentEmoji(agent: AgentData): string | undefined {
|
||||
return (agent.other_config?.emoji as string) || undefined;
|
||||
}
|
||||
|
||||
export function AgentSelector({ value, onChange }: AgentSelectorProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const http = useHttp();
|
||||
@@ -28,7 +33,6 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
|
||||
.then((res) => {
|
||||
const active = (res.agents ?? []).filter((a) => a.status === "active");
|
||||
setAgents(active);
|
||||
// Auto-select first agent if current value doesn't match any active agent
|
||||
if (active.length > 0 && !active.some((a) => a.agent_key === value)) {
|
||||
onChange(active[0]!.agent_key);
|
||||
}
|
||||
@@ -36,7 +40,6 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
|
||||
.catch(() => {});
|
||||
}, [http, connected]);
|
||||
|
||||
// Compute dropdown position for portal rendering
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
@@ -49,7 +52,6 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -66,6 +68,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
|
||||
}, [open]);
|
||||
|
||||
const selected = agents.find((a) => a.agent_key === value);
|
||||
const selectedEmoji = selected ? agentEmoji(selected) : undefined;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
@@ -74,8 +77,12 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center gap-2 rounded-lg border bg-background px-3 py-2 text-sm hover:bg-accent"
|
||||
>
|
||||
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedEmoji ? (
|
||||
<span className="text-base shrink-0">{selectedEmoji}</span>
|
||||
) : (
|
||||
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-left font-medium">
|
||||
{selected?.display_name ?? selected?.agent_key ?? (value || t("selectAgent"))}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
@@ -85,35 +92,39 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={dropdownStyle}
|
||||
className="max-h-60 overflow-y-auto rounded-lg border bg-popover p-1 shadow-md"
|
||||
className="pointer-events-auto max-h-60 overflow-y-auto rounded-lg border bg-popover p-1 shadow-md"
|
||||
>
|
||||
{agents.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{t("noAgentsAvailable")}
|
||||
</div>
|
||||
)}
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.agent_key}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onChange(agent.agent_key);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent ${
|
||||
agent.agent_key === value ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-left">
|
||||
{agent.display_name || agent.agent_key}
|
||||
</span>
|
||||
{agent.is_default && (
|
||||
<span className="text-xs text-muted-foreground">{t("default")}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{agents.map((agent) => {
|
||||
const emoji = agentEmoji(agent);
|
||||
return (
|
||||
<button
|
||||
key={agent.agent_key}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => { onChange(agent.agent_key); setOpen(false); }}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent ${
|
||||
agent.agent_key === value ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
{emoji ? (
|
||||
<span className="text-base shrink-0">{emoji}</span>
|
||||
) : (
|
||||
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-left">
|
||||
{agent.display_name || agent.agent_key}
|
||||
</span>
|
||||
{agent.is_default && (
|
||||
<span className="text-xs text-muted-foreground">{t("default")}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Bot, User } from "lucide-react";
|
||||
import { MessageContent } from "./message-content";
|
||||
import { ThinkingBlock } from "./thinking-block";
|
||||
import { ToolCallCard } from "./tool-call-card";
|
||||
import { BlockReplyBubble } from "./block-reply-bubble";
|
||||
import type { ChatMessage } from "@/types/chat";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
@@ -12,9 +13,9 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isUser = message.role === "user";
|
||||
const isTool = message.role === "tool";
|
||||
|
||||
if (isTool) {
|
||||
return null; // Tool messages are shown inline with assistant messages
|
||||
}
|
||||
if (isTool) return null;
|
||||
if (message.isNotification) return null;
|
||||
if (message.isBlockReply) return <BlockReplyBubble message={message} />;
|
||||
|
||||
const isAssistant = message.role === "assistant";
|
||||
const hasThinking = isAssistant && !!message.thinking;
|
||||
@@ -22,50 +23,56 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const hasToolCalls = isAssistant && message.tool_calls && message.tool_calls.length > 0;
|
||||
const hasContent = !!message.content?.trim();
|
||||
|
||||
// Skip assistant messages with no content at all
|
||||
if (isAssistant && !hasContent && !hasToolCalls && !hasToolDetails) {
|
||||
return null;
|
||||
}
|
||||
if (isAssistant && !hasContent && !hasToolCalls && !hasToolDetails) return null;
|
||||
|
||||
// Tool-only message (no text content) — render compact without bubble wrapper
|
||||
const isToolOnly = isAssistant && !hasContent && !hasThinking && (hasToolDetails || hasToolCalls);
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border bg-background">
|
||||
{isUser ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4" />
|
||||
)}
|
||||
{isUser ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-4 py-2 ${
|
||||
{isToolOnly ? (
|
||||
/* Compact tool-only card — no bubble wrapper, full width */
|
||||
<div className="flex-1 min-w-0 rounded-md border bg-muted/30 divide-y divide-border">
|
||||
{hasThinking && (
|
||||
<div className="px-2 py-1.5">
|
||||
<ThinkingBlock text={message.thinking!} />
|
||||
</div>
|
||||
)}
|
||||
{hasToolDetails && message.toolDetails!.map((entry) => (
|
||||
<ToolCallCard key={entry.toolCallId} entry={entry} compact />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Normal message bubble */
|
||||
<div className={`max-w-[85%] rounded-lg px-4 py-2 ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-card text-card-foreground border border-border shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{hasThinking && (
|
||||
<div className="mb-2">
|
||||
<ThinkingBlock text={message.thinking!} />
|
||||
</div>
|
||||
)}
|
||||
{hasToolDetails && (
|
||||
<div className="mb-2">
|
||||
{message.toolDetails!.map((entry) => (
|
||||
<ToolCallCard key={entry.toolCallId} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MessageContent content={message.content} role={message.role} />
|
||||
{message.timestamp && (
|
||||
<div className={`mt-1 text-[10px] ${isUser ? "text-primary-foreground/60" : "text-muted-foreground"}`}>
|
||||
{new Date(message.timestamp).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}`}>
|
||||
{hasThinking && (
|
||||
<div className="mb-2">
|
||||
<ThinkingBlock text={message.thinking!} />
|
||||
</div>
|
||||
)}
|
||||
{hasToolDetails && (
|
||||
<div className="mb-2 rounded-md border bg-muted/30 divide-y divide-border">
|
||||
{message.toolDetails!.map((entry) => (
|
||||
<ToolCallCard key={entry.toolCallId} entry={entry} compact />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MessageContent content={message.content} role={message.role} />
|
||||
{message.timestamp && (
|
||||
<div className={`mt-1 text-[10px] ${isUser ? "text-primary-foreground/60" : "text-muted-foreground"}`}>
|
||||
{new Date(message.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Image,
|
||||
@@ -8,10 +8,16 @@ import {
|
||||
Forward,
|
||||
Reply,
|
||||
MapPin,
|
||||
ChevronDown,
|
||||
Download,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// --- Patterns to detect special blocks ---
|
||||
|
||||
@@ -166,29 +172,64 @@ function ReplyQuote({ sender, body }: { sender: string; body: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether file content should be rendered as markdown (vs raw code) */
|
||||
function isMarkdownFile(name: string, mime: string): boolean {
|
||||
return /\.(md|mdx|markdown)$/i.test(name) || mime.startsWith("text/markdown");
|
||||
}
|
||||
|
||||
function FileBlock({ name, mime, content }: { name: string; mime: string; content: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const blob = new Blob([content], { type: mime || "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [content, mime, name]);
|
||||
|
||||
const renderMarkdown = isMarkdownFile(name, mime);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30">
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-left text-xs font-medium hover:bg-muted/50"
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span>{name}</span>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span className="text-muted-foreground font-normal">{mime}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap text-xs">
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-row items-center justify-between gap-2">
|
||||
<DialogTitle className="truncate text-base">{name}</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="mr-8 flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
</button>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border bg-muted/20 p-4">
|
||||
{renderMarkdown ? (
|
||||
<MarkdownRenderer content={content} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono">
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import { parseSessionKey } from "@/lib/session-key";
|
||||
import type { SessionInfo } from "@/types/session";
|
||||
|
||||
interface SessionSwitcherProps {
|
||||
@@ -11,18 +10,44 @@ interface SessionSwitcherProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function SessionSwitcher({
|
||||
sessions,
|
||||
activeKey,
|
||||
onSelect,
|
||||
loading,
|
||||
}: SessionSwitcherProps) {
|
||||
/** Build a human-friendly label from session metadata or key */
|
||||
function sessionLabel(session: SessionInfo): string {
|
||||
// Prefer explicit titles/labels
|
||||
if (session.metadata?.chat_title) return session.metadata.chat_title;
|
||||
if (session.metadata?.display_name) return session.metadata.display_name;
|
||||
if (session.label) return session.label;
|
||||
|
||||
// Parse the scope part from "agent:{id}:{scope}"
|
||||
const parts = session.key.split(":");
|
||||
const scope = parts.length >= 3 ? parts.slice(2).join(":") : session.key;
|
||||
|
||||
// Friendly format for known patterns
|
||||
if (scope.startsWith("ws-")) {
|
||||
// "ws-system-mmxarlgc" → "Chat mmxarlgc"
|
||||
const segments = scope.split("-");
|
||||
const shortId = segments[segments.length - 1] ?? scope;
|
||||
return `Chat ${shortId}`;
|
||||
}
|
||||
if (scope.startsWith("ws:direct:")) {
|
||||
// "ws:direct:uuid" → "Chat uuid (short)"
|
||||
const uuid = scope.replace("ws:direct:", "");
|
||||
return `Chat ${uuid.slice(0, 8)}`;
|
||||
}
|
||||
if (scope.startsWith("team:")) return `Team ${scope.replace("team:", "").slice(0, 12)}`;
|
||||
if (scope.startsWith("cron:")) return `Cron ${scope.replace("cron:", "")}`;
|
||||
|
||||
// Fallback: truncate long scope
|
||||
return scope.length > 24 ? scope.slice(0, 21) + "…" : scope;
|
||||
}
|
||||
|
||||
export function SessionSwitcher({ sessions, activeKey, onSelect, loading }: SessionSwitcherProps) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (sessions.length === 0 && loading) {
|
||||
return (
|
||||
<div className="space-y-2 p-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-14 animate-pulse rounded-lg bg-muted" />
|
||||
<div key={i} className="h-12 animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -37,28 +62,26 @@ export function SessionSwitcher({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1 p-2">
|
||||
<div className="space-y-0.5 p-1.5">
|
||||
{sessions.map((session) => {
|
||||
const parsed = parseSessionKey(session.key);
|
||||
const isActive = session.key === activeKey;
|
||||
const label = session.metadata?.chat_title || session.metadata?.display_name || session.label || parsed.scope || session.key;
|
||||
const label = sessionLabel(session);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={session.key}
|
||||
type="button"
|
||||
onClick={() => onSelect(session.key)}
|
||||
className={`flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted"
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{label}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="truncate font-medium text-[13px]">{label}</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{session.messageCount} {t("messages")}</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelativeTime(session.updated)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,65 +10,63 @@ function buildToolSummary(entry: ToolStreamEntry): string | null {
|
||||
if (!entry.arguments) return null;
|
||||
const args = entry.arguments;
|
||||
const key = args.path ?? args.command ?? args.query ?? args.url ?? args.name;
|
||||
if (typeof key === "string") return key.length > 60 ? key.slice(0, 57) + "..." : key;
|
||||
if (typeof key === "string") return key.length > 80 ? key.slice(0, 77) + "..." : key;
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ToolCallCardProps {
|
||||
entry: ToolStreamEntry;
|
||||
/** Compact mode — less padding, used inside merged groups */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ToolCallCard({ entry }: ToolCallCardProps) {
|
||||
export function ToolCallCard({ entry, compact }: ToolCallCardProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const hasDetails = entry.arguments || entry.result;
|
||||
const hasError = entry.phase === "error" && !!entry.errorContent;
|
||||
const canExpand = hasDetails || hasError;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const summary = buildToolSummary(entry);
|
||||
const skill = isSkillTool(entry.name);
|
||||
const displayName = skill ? `skill: ${(entry.arguments?.name as string) || "unknown"}` : entry.name;
|
||||
|
||||
return (
|
||||
<div className="my-1 rounded-md border bg-muted/50 text-sm">
|
||||
<div className={compact ? "" : "rounded-md border bg-muted/30"}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs"
|
||||
onClick={() => canExpand && setExpanded((v) => !v)}
|
||||
disabled={!canExpand}
|
||||
>
|
||||
<ToolIcon phase={entry.phase} isSkill={isSkillTool(entry.name)} />
|
||||
<span className="font-medium shrink-0">
|
||||
{isSkillTool(entry.name)
|
||||
? `skill: ${(entry.arguments?.name as string) || "unknown"}`
|
||||
: entry.name}
|
||||
</span>
|
||||
{summary && (
|
||||
<span className="truncate text-xs text-muted-foreground">{summary}</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-2 shrink-0">
|
||||
<PhaseLabel phase={entry.phase} isSkill={isSkillTool(entry.name)} />
|
||||
<ToolIcon phase={entry.phase} isSkill={skill} />
|
||||
<span className="font-medium shrink-0">{displayName}</span>
|
||||
{summary && <span className="truncate text-muted-foreground ml-1">{summary}</span>}
|
||||
<span className="ml-auto flex items-center gap-1 shrink-0">
|
||||
<PhaseLabel phase={entry.phase} isSkill={skill} />
|
||||
{canExpand && (
|
||||
expanded
|
||||
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
? <ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
: <ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && canExpand && (
|
||||
<div className="border-t border-muted px-3 py-2 space-y-2">
|
||||
<div className="border-t border-muted px-2 py-1.5 space-y-1.5">
|
||||
{hasError && (
|
||||
<pre className="text-red-500 whitespace-pre-wrap">{entry.errorContent}</pre>
|
||||
<pre className="text-red-500 whitespace-pre-wrap text-xs">{entry.errorContent}</pre>
|
||||
)}
|
||||
{entry.arguments && Object.keys(entry.arguments).length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground mb-1">{t("toolArguments")}</div>
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono bg-background rounded p-2 max-h-48 overflow-y-auto">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground mb-0.5">{t("toolArguments")}</div>
|
||||
<pre className="whitespace-pre-wrap text-[11px] font-mono bg-background rounded p-1.5 max-h-40 overflow-y-auto">
|
||||
{JSON.stringify(entry.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{entry.result && (
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground mb-1">{t("toolResult")}</div>
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono bg-background rounded p-2 max-h-48 overflow-y-auto">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground mb-0.5">{t("toolResult")}</div>
|
||||
<pre className="whitespace-pre-wrap text-[11px] font-mono bg-background rounded p-1.5 max-h-40 overflow-y-auto">
|
||||
{entry.result}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -80,27 +78,20 @@ export function ToolCallCard({ entry }: ToolCallCardProps) {
|
||||
}
|
||||
|
||||
function ToolIcon({ phase, isSkill }: { phase: ToolStreamEntry["phase"]; isSkill?: boolean }) {
|
||||
const cls = "h-3.5 w-3.5";
|
||||
if (isSkill) {
|
||||
switch (phase) {
|
||||
case "calling":
|
||||
return <Zap className="h-4 w-4 animate-pulse text-amber-500" />;
|
||||
case "completed":
|
||||
return <Zap className="h-4 w-4 text-amber-500" />;
|
||||
case "error":
|
||||
return <AlertTriangle className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return <Zap className="h-4 w-4 text-muted-foreground" />;
|
||||
case "calling": return <Zap className={`${cls} animate-pulse text-amber-500`} />;
|
||||
case "completed": return <Zap className={`${cls} text-amber-500`} />;
|
||||
case "error": return <AlertTriangle className={`${cls} text-red-500`} />;
|
||||
default: return <Zap className={`${cls} text-muted-foreground`} />;
|
||||
}
|
||||
}
|
||||
switch (phase) {
|
||||
case "calling":
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-blue-500" />;
|
||||
case "completed":
|
||||
return <Check className="h-4 w-4 text-green-500" />;
|
||||
case "error":
|
||||
return <AlertTriangle className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return <Wrench className="h-4 w-4 text-muted-foreground" />;
|
||||
case "calling": return <Loader2 className={`${cls} animate-spin text-blue-500`} />;
|
||||
case "completed": return <Check className={`${cls} text-green-500`} />;
|
||||
case "error": return <AlertTriangle className={`${cls} text-red-500`} />;
|
||||
default: return <Wrench className={`${cls} text-muted-foreground`} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +105,5 @@ function PhaseLabel({ phase, isSkill }: { phase: ToolStreamEntry["phase"]; isSki
|
||||
completed: "text-green-500",
|
||||
error: "text-red-500",
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs ${colors[phase] ?? "text-muted-foreground"}`}>
|
||||
{labels[phase] ?? phase}
|
||||
</span>
|
||||
);
|
||||
return <span className={`text-[11px] ${colors[phase] ?? "text-muted-foreground"}`}>{labels[phase] ?? phase}</span>;
|
||||
}
|
||||
@@ -4,8 +4,14 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Check, Copy, Download, FileText } from "lucide-react";
|
||||
import { ImageLightbox } from "./image-lightbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function CodeBlock({
|
||||
className,
|
||||
@@ -44,9 +50,50 @@ interface MarkdownRendererProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Check if a URL points to a local file via /v1/files/ endpoint */
|
||||
function isFileLink(href: string | undefined): boolean {
|
||||
return !!href && (href.startsWith("/v1/files/") || href.includes("/v1/files/"));
|
||||
}
|
||||
|
||||
/** File type detection from name */
|
||||
function isMarkdownExt(name: string): boolean {
|
||||
return /\.(md|mdx|markdown)$/i.test(name);
|
||||
}
|
||||
function isMediaFile(name: string): "image" | "audio" | "video" | null {
|
||||
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i.test(name)) return "image";
|
||||
if (/\.(mp3|wav|ogg|flac|aac|m4a|wma|opus)$/i.test(name)) return "audio";
|
||||
if (/\.(mp4|webm|mkv|avi|mov|wmv)$/i.test(name)) return "video";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract filename from /v1/files/ URL */
|
||||
function fileNameFromHref(href: string): string {
|
||||
const path = href.split("?")[0] ?? href;
|
||||
const segments = path.split("/");
|
||||
return segments[segments.length - 1] ?? "file";
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
|
||||
const openLightbox = useCallback((src: string, alt: string) => setLightbox({ src, alt }), []);
|
||||
const [filePreview, setFilePreview] = useState<{ name: string; href: string; content: string; mediaType?: "image" | "audio" | "video" } | null>(null);
|
||||
const [fileLoading, setFileLoading] = useState(false);
|
||||
|
||||
const handleFileClick = useCallback((href: string, name: string) => {
|
||||
// Media files: open preview directly without fetching text content
|
||||
const media = isMediaFile(name);
|
||||
if (media) {
|
||||
setFilePreview({ name, href, content: "", mediaType: media });
|
||||
return;
|
||||
}
|
||||
// Text/code files: fetch content
|
||||
setFileLoading(true);
|
||||
fetch(href)
|
||||
.then((res) => res.text())
|
||||
.then((text) => setFilePreview({ name, href, content: text }))
|
||||
.catch(() => window.open(href, "_blank"))
|
||||
.finally(() => setFileLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`md-render prose dark:prose-invert max-w-none break-words ${className ?? ""}`}>
|
||||
@@ -73,9 +120,32 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
||||
</code>
|
||||
);
|
||||
},
|
||||
a({ href, children, ...props }) {
|
||||
a({ href, children }) {
|
||||
if (isFileLink(href)) {
|
||||
const name = typeof children === "string" ? children : fileNameFromHref(href ?? "");
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded border bg-muted/50 text-[0.85em] font-medium">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-primary hover:bg-muted cursor-pointer rounded-l"
|
||||
onClick={(e) => { e.preventDefault(); handleFileClick(href!, name); }}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{children}
|
||||
</button>
|
||||
<a
|
||||
href={href}
|
||||
download={name}
|
||||
className="inline-flex items-center px-1 py-0.5 text-muted-foreground hover:bg-muted cursor-pointer rounded-r border-l"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
@@ -134,6 +204,43 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
|
||||
{fileLoading && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/50">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={!!filePreview} onOpenChange={(open) => { if (!open) setFilePreview(null); }}>
|
||||
{filePreview && (
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-row items-center justify-between gap-2">
|
||||
<DialogTitle className="truncate text-base">{filePreview.name}</DialogTitle>
|
||||
<a
|
||||
href={filePreview.href}
|
||||
download={filePreview.name}
|
||||
className="mr-8 flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
</a>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border bg-muted/20 p-4">
|
||||
{filePreview.mediaType === "image" ? (
|
||||
<img src={filePreview.href} alt={filePreview.name} className="max-w-full rounded" />
|
||||
) : filePreview.mediaType === "audio" ? (
|
||||
<audio controls src={filePreview.href} className="w-full" />
|
||||
) : filePreview.mediaType === "video" ? (
|
||||
<video controls src={filePreview.href} className="max-w-full rounded" />
|
||||
) : isMarkdownExt(filePreview.name) ? (
|
||||
<MarkdownRenderer content={filePreview.content} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono"><code>{filePreview.content}</code></pre>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { cn } from "@/lib/utils";
|
||||
import { ChatSidebar } from "./chat-sidebar";
|
||||
import { ChatThread } from "./chat-thread";
|
||||
import { ChatInput, type AttachedFile } from "@/components/chat/chat-input";
|
||||
import { ChatTopBar } from "@/components/chat/chat-top-bar";
|
||||
import { DropZone } from "@/components/chat/drop-zone";
|
||||
import { useChatSessions } from "./hooks/use-chat-sessions";
|
||||
import { useChatMessages } from "./hooks/use-chat-messages";
|
||||
import { useChatSend } from "./hooks/use-chat-send";
|
||||
@@ -46,6 +48,9 @@ export function ChatPage() {
|
||||
toolStream,
|
||||
isRunning,
|
||||
loading: messagesLoading,
|
||||
activity,
|
||||
blockReplies,
|
||||
teamTasks,
|
||||
expectRun,
|
||||
addLocalMessage,
|
||||
} = useChatMessages(sessionKey, agentId);
|
||||
@@ -200,35 +205,42 @@ export function ChatPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatTopBar agentId={agentId} isRunning={isRunning} activity={activity} />
|
||||
|
||||
{sendError && (
|
||||
<div className="border-b bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||
{sendError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatThread
|
||||
messages={messages}
|
||||
streamText={streamText}
|
||||
thinkingText={thinkingText}
|
||||
toolStream={toolStream}
|
||||
isRunning={isRunning}
|
||||
loading={messagesLoading}
|
||||
scrollTrigger={scrollTrigger}
|
||||
/>
|
||||
|
||||
{isOwn ? (
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onAbort={handleAbort}
|
||||
<DropZone onDrop={() => { /* TODO: wire to chat input */ }}>
|
||||
<ChatThread
|
||||
messages={messages}
|
||||
streamText={streamText}
|
||||
thinkingText={thinkingText}
|
||||
toolStream={toolStream}
|
||||
blockReplies={blockReplies}
|
||||
activity={activity}
|
||||
teamTasks={teamTasks}
|
||||
isRunning={isRunning}
|
||||
disabled={!connected}
|
||||
loading={messagesLoading}
|
||||
scrollTrigger={scrollTrigger}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 border-t bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
||||
<Eye className="h-4 w-4" />
|
||||
{t("readOnly")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOwn ? (
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onAbort={handleAbort}
|
||||
isRunning={isRunning}
|
||||
disabled={!connected}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 border-t bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
||||
<Eye className="h-4 w-4" />
|
||||
{t("readOnly")}
|
||||
</div>
|
||||
)}
|
||||
</DropZone>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,78 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Circle } from "lucide-react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { MessageBubble } from "@/components/chat/message-bubble";
|
||||
import { StreamingText } from "@/components/chat/streaming-text";
|
||||
import { ActiveRunZone } from "@/components/chat/active-run-zone";
|
||||
import { SystemNotification } from "@/components/chat/system-notification";
|
||||
import { TeamActivityPanel } from "@/components/chat/team-activity-panel";
|
||||
import { ToolCallCard } from "@/components/chat/tool-call-card";
|
||||
import { ThinkingIndicator } from "@/components/chat/thinking-indicator";
|
||||
import { ThinkingBlock } from "@/components/chat/thinking-block";
|
||||
import { useAutoScroll } from "@/hooks/use-auto-scroll";
|
||||
import type { ChatMessage, ToolStreamEntry } from "@/types/chat";
|
||||
import type { ChatMessage, ToolStreamEntry, RunActivity, ActiveTeamTask } from "@/types/chat";
|
||||
|
||||
interface ChatThreadProps {
|
||||
messages: ChatMessage[];
|
||||
streamText: string | null;
|
||||
thinkingText: string | null;
|
||||
toolStream: ToolStreamEntry[];
|
||||
blockReplies: ChatMessage[];
|
||||
activity: RunActivity | null;
|
||||
teamTasks: ActiveTeamTask[];
|
||||
isRunning: boolean;
|
||||
loading?: boolean;
|
||||
scrollTrigger?: number;
|
||||
}
|
||||
|
||||
/** Check if a message is tool-only (no user-visible text content) */
|
||||
function isToolOnlyMsg(msg: ChatMessage): boolean {
|
||||
if (msg.role !== "assistant") return false;
|
||||
const hasContent = !!msg.content?.trim();
|
||||
const hasTools = (msg.toolDetails && msg.toolDetails.length > 0) || (msg.tool_calls && msg.tool_calls.length > 0);
|
||||
return !hasContent && !!hasTools;
|
||||
}
|
||||
|
||||
type DisplayItem =
|
||||
| { kind: "message"; msg: ChatMessage; idx: number }
|
||||
| { kind: "notification"; msg: ChatMessage; idx: number }
|
||||
| { kind: "merged-tools"; msgs: ChatMessage[]; idx: number };
|
||||
|
||||
/** Merge consecutive tool-only assistant messages into single groups */
|
||||
function buildDisplayItems(messages: ChatMessage[]): DisplayItem[] {
|
||||
const filtered = messages.filter(
|
||||
(msg) => !(msg.role === "user" && typeof msg.content === "string" && msg.content.startsWith("[System]")),
|
||||
);
|
||||
|
||||
const items: DisplayItem[] = [];
|
||||
let toolGroup: ChatMessage[] = [];
|
||||
let groupStartIdx = 0;
|
||||
|
||||
const flushToolGroup = () => {
|
||||
if (toolGroup.length > 0) {
|
||||
items.push({ kind: "merged-tools", msgs: toolGroup, idx: groupStartIdx });
|
||||
toolGroup = [];
|
||||
}
|
||||
};
|
||||
|
||||
filtered.forEach((msg, i) => {
|
||||
if (msg.isNotification) {
|
||||
flushToolGroup();
|
||||
items.push({ kind: "notification", msg, idx: i });
|
||||
} else if (isToolOnlyMsg(msg)) {
|
||||
if (toolGroup.length === 0) groupStartIdx = i;
|
||||
toolGroup.push(msg);
|
||||
} else {
|
||||
flushToolGroup();
|
||||
items.push({ kind: "message", msg, idx: i });
|
||||
}
|
||||
});
|
||||
flushToolGroup();
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function ChatThread({
|
||||
messages,
|
||||
streamText,
|
||||
thinkingText,
|
||||
toolStream,
|
||||
isRunning,
|
||||
loading,
|
||||
scrollTrigger = 0,
|
||||
messages, streamText, thinkingText, toolStream, blockReplies,
|
||||
activity, teamTasks, isRunning, loading, scrollTrigger = 0,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const { ref, onScroll } = useAutoScroll<HTMLDivElement>(
|
||||
@@ -34,7 +81,8 @@ export function ChatThread({
|
||||
scrollTrigger,
|
||||
);
|
||||
|
||||
// Show spinner while loading history for a different session
|
||||
const displayItems = useMemo(() => buildDisplayItems(messages), [messages]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
@@ -53,55 +101,54 @@ export function ChatThread({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onScroll={onScroll}
|
||||
className="flex-1 overflow-y-auto overscroll-contain px-4 py-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{messages
|
||||
.filter((msg) => !(msg.role === "user" && typeof msg.content === "string" && msg.content.startsWith("[System]")))
|
||||
.map((msg, i) => (
|
||||
<MessageBubble key={`${msg.role}-${i}`} message={msg} />
|
||||
))}
|
||||
<div ref={ref} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-4 py-4">
|
||||
<div className="mx-auto max-w-3xl space-y-3">
|
||||
{displayItems.map((item) => {
|
||||
switch (item.kind) {
|
||||
case "notification":
|
||||
return <SystemNotification key={`notif-${item.idx}`} message={item.msg} />;
|
||||
case "message":
|
||||
return <MessageBubble key={`msg-${item.idx}`} message={item.msg} />;
|
||||
case "merged-tools":
|
||||
return <MergedToolGroup key={`tools-${item.idx}`} msgs={item.msgs} />;
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Tool stream during active run */}
|
||||
{toolStream.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{toolStream.map((entry) => (
|
||||
<ToolCallCard key={entry.toolCallId} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{teamTasks.length > 0 && <TeamActivityPanel tasks={teamTasks} />}
|
||||
|
||||
{/* Thinking block (extended thinking / reasoning) */}
|
||||
{isRunning && thinkingText && (
|
||||
<ThinkingBlock text={thinkingText} isStreaming={streamText === null} />
|
||||
)}
|
||||
<ActiveRunZone
|
||||
isRunning={isRunning}
|
||||
activity={activity}
|
||||
thinkingText={thinkingText}
|
||||
streamText={streamText}
|
||||
toolStream={toolStream}
|
||||
blockReplies={blockReplies}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Streaming text */}
|
||||
{isRunning && streamText !== null && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border bg-background">
|
||||
<Circle className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="max-w-[80%] rounded-lg bg-muted px-4 py-2">
|
||||
<StreamingText text={streamText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/** Renders multiple consecutive tool-only messages as a single compact card */
|
||||
function MergedToolGroup({ msgs }: { msgs: ChatMessage[] }) {
|
||||
// Collect all tool details from all messages
|
||||
const allTools = msgs.flatMap((msg) => msg.toolDetails ?? []);
|
||||
const allThinking = msgs.map((m) => m.thinking).filter(Boolean);
|
||||
|
||||
{/* Thinking indicator when running but no stream yet */}
|
||||
{isRunning && streamText === null && !thinkingText && toolStream.length === 0 && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border bg-background">
|
||||
<Circle className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted px-4 py-2">
|
||||
<ThinkingIndicator />
|
||||
</div>
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border bg-background">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 rounded-md border bg-muted/30 divide-y divide-border">
|
||||
{allThinking.length > 0 && (
|
||||
<div className="px-2 py-1.5">
|
||||
<ThinkingBlock text={allThinking.join("\n\n")} />
|
||||
</div>
|
||||
)}
|
||||
{allTools.map((entry) => (
|
||||
<ToolCallCard key={entry.toolCallId} entry={entry} compact />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user