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:
viettranx committed 2026-03-19 19:01:51 +07:00
1 parent a9ec6302ca
commit 15916823e0
8 files changed
+458 -223

No files matched your search

+39 -28
View File
@@ -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,
)}
+45 -38
View File
@@ -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>
);
}
+57 -16
View File
@@ -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>
</>
);
}
+41 -18
View File
@@ -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>
+31 -44
View File
@@ -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>
);
}
+33 -21
View File
@@ -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>
);
+102 -55
View File
@@ -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>
);