From 15916823e053d632e7513ecea5e79dc615d9e23a Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 19 Mar 2026 19:01:51 +0700 Subject: [PATCH] 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 --- ui/web/src/components/chat/agent-selector.tsx | 67 ++++---- ui/web/src/components/chat/message-bubble.tsx | 83 ++++----- ui/web/src/components/chat/rich-content.tsx | 73 ++++++-- .../src/components/chat/session-switcher.tsx | 59 +++++-- ui/web/src/components/chat/tool-call-card.tsx | 75 ++++----- .../components/shared/markdown-renderer.tsx | 113 ++++++++++++- ui/web/src/pages/chat/chat-page.tsx | 54 +++--- ui/web/src/pages/chat/chat-thread.tsx | 157 ++++++++++++------ 8 files changed, 458 insertions(+), 223 deletions(-) diff --git a/ui/web/src/components/chat/agent-selector.tsx b/ui/web/src/components/chat/agent-selector.tsx index 3d08db5c..8429929a 100644 --- a/ui/web/src/components/chat/agent-selector.tsx +++ b/ui/web/src/components/chat/agent-selector.tsx @@ -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 (
@@ -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" > - - + {selectedEmoji ? ( + {selectedEmoji} + ) : ( + + )} + {selected?.display_name ?? selected?.agent_key ?? (value || t("selectAgent"))} @@ -85,35 +92,39 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
{agents.length === 0 && (
{t("noAgentsAvailable")}
)} - {agents.map((agent) => ( - - ))} + {agents.map((agent) => { + const emoji = agentEmoji(agent); + return ( + + ); + })}
, document.body, )} diff --git a/ui/web/src/components/chat/message-bubble.tsx b/ui/web/src/components/chat/message-bubble.tsx index c22a3d98..abfb5ca9 100644 --- a/ui/web/src/components/chat/message-bubble.tsx +++ b/ui/web/src/components/chat/message-bubble.tsx @@ -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 ; 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 (
- {isUser ? ( - - ) : ( - - )} + {isUser ? : }
-
+ {hasThinking && ( +
+ +
+ )} + {hasToolDetails && message.toolDetails!.map((entry) => ( + + ))} +
+ ) : ( + /* Normal message bubble */ +
- {hasThinking && ( -
- -
- )} - {hasToolDetails && ( -
- {message.toolDetails!.map((entry) => ( - - ))} -
- )} - - {message.timestamp && ( -
- {new Date(message.timestamp).toLocaleTimeString([], { - hour: "numeric", - minute: "2-digit", - })} -
- )} -
+ }`}> + {hasThinking && ( +
+ +
+ )} + {hasToolDetails && ( +
+ {message.toolDetails!.map((entry) => ( + + ))} +
+ )} + + {message.timestamp && ( +
+ {new Date(message.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} +
+ )} +
+ )}
); } diff --git a/ui/web/src/components/chat/rich-content.tsx b/ui/web/src/components/chat/rich-content.tsx index daa823c4..35cf9d10 100644 --- a/ui/web/src/components/chat/rich-content.tsx +++ b/ui/web/src/components/chat/rich-content.tsx @@ -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 ( -
+ <> - {expanded && ( -
-
-            {content}
-          
-
- )} -
+ + + + + {name} + + +
+ {renderMarkdown ? ( + + ) : ( +
+                {content}
+              
+ )} +
+
+
+ ); } diff --git a/ui/web/src/components/chat/session-switcher.tsx b/ui/web/src/components/chat/session-switcher.tsx index 3dfe5f24..d8bb476f 100644 --- a/ui/web/src/components/chat/session-switcher.tsx +++ b/ui/web/src/components/chat/session-switcher.tsx @@ -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 (
{Array.from({ length: 3 }).map((_, i) => ( -
+
))}
); @@ -37,28 +62,26 @@ export function SessionSwitcher({ } return ( -
+
{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 ( {expanded && canExpand && ( -
+
{hasError && ( -
{entry.errorContent}
+
{entry.errorContent}
)} {entry.arguments && Object.keys(entry.arguments).length > 0 && (
-
{t("toolArguments")}
-
+              
{t("toolArguments")}
+
                 {JSON.stringify(entry.arguments, null, 2)}
               
)} {entry.result && (
-
{t("toolResult")}
-
+              
{t("toolResult")}
+
                 {entry.result}
               
@@ -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 ; - case "completed": - return ; - case "error": - return ; - default: - return ; + case "calling": return ; + case "completed": return ; + case "error": return ; + default: return ; } } switch (phase) { - case "calling": - return ; - case "completed": - return ; - case "error": - return ; - default: - return ; + case "calling": return ; + case "completed": return ; + case "error": return ; + default: return ; } } @@ -114,9 +105,5 @@ function PhaseLabel({ phase, isSkill }: { phase: ToolStreamEntry["phase"]; isSki completed: "text-green-500", error: "text-red-500", }; - return ( - - {labels[phase] ?? phase} - - ); + return {labels[phase] ?? phase}; } diff --git a/ui/web/src/components/shared/markdown-renderer.tsx b/ui/web/src/components/shared/markdown-renderer.tsx index 4330c0ff..f7015c72 100644 --- a/ui/web/src/components/shared/markdown-renderer.tsx +++ b/ui/web/src/components/shared/markdown-renderer.tsx @@ -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 (
@@ -73,9 +120,32 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) ); }, - a({ href, children, ...props }) { + a({ href, children }) { + if (isFileLink(href)) { + const name = typeof children === "string" ? children : fileNameFromHref(href ?? ""); + return ( + + + e.stopPropagation()} + > + + + + ); + } return ( - + {children} ); @@ -134,6 +204,43 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) > {content} + + {fileLoading && ( +
+
+
+ )} + + { if (!open) setFilePreview(null); }}> + {filePreview && ( + + + {filePreview.name} + + + Download + + +
+ {filePreview.mediaType === "image" ? ( + {filePreview.name} + ) : filePreview.mediaType === "audio" ? ( +
+
+ )} +
); } diff --git a/ui/web/src/pages/chat/chat-page.tsx b/ui/web/src/pages/chat/chat-page.tsx index 048573cc..11e5ef13 100644 --- a/ui/web/src/pages/chat/chat-page.tsx +++ b/ui/web/src/pages/chat/chat-page.tsx @@ -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() {
)} + + {sendError && (
{sendError}
)} - - - {isOwn ? ( - { /* TODO: wire to chat input */ }}> + - ) : ( -
- - {t("readOnly")} -
- )} + + {isOwn ? ( + + ) : ( +
+ + {t("readOnly")} +
+ )} +
); diff --git a/ui/web/src/pages/chat/chat-thread.tsx b/ui/web/src/pages/chat/chat-thread.tsx index ca4e0a45..ba34f075 100644 --- a/ui/web/src/pages/chat/chat-thread.tsx +++ b/ui/web/src/pages/chat/chat-thread.tsx @@ -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( @@ -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 (
@@ -53,55 +101,54 @@ export function ChatThread({ } return ( -
-
- {messages - .filter((msg) => !(msg.role === "user" && typeof msg.content === "string" && msg.content.startsWith("[System]"))) - .map((msg, i) => ( - - ))} +
+
+ {displayItems.map((item) => { + switch (item.kind) { + case "notification": + return ; + case "message": + return ; + case "merged-tools": + return ; + } + })} - {/* Tool stream during active run */} - {toolStream.length > 0 && ( -
- {toolStream.map((entry) => ( - - ))} -
- )} + {teamTasks.length > 0 && } - {/* Thinking block (extended thinking / reasoning) */} - {isRunning && thinkingText && ( - - )} + +
+
+ ); +} - {/* Streaming text */} - {isRunning && streamText !== null && ( -
-
- -
-
- -
-
- )} +/** 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 && ( -
-
- -
-
- -
+ return ( +
+
+ +
+
+ {allThinking.length > 0 && ( +
+
)} + {allTools.map((entry) => ( + + ))}
);