refactor(ui): split Chat into focused components with skeleton loading
- Extract ConnectPrompt: self-contained connection code input - Extract MessageList: pure display of messages with streaming support - Add ChatSkeleton: skeleton placeholder shown during reconnection - Chat component: three-state rendering (skeleton → connect → messages), keeps <main ref> always mounted so useScrollFade works correctly - Remove hub-sidebar.tsx (dead code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
162a86dff4
commit
62267aaf19
6 changed files with 195 additions and 303 deletions
41
packages/ui/src/components/chat-skeleton.tsx
Normal file
41
packages/ui/src/components/chat-skeleton.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
|
||||
/** Skeleton placeholder matching MessageList layout, shown while reconnecting */
|
||||
export function ChatSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-6 space-y-6 max-w-4xl mx-auto">
|
||||
{/* Assistant message */}
|
||||
<div className="flex justify-start">
|
||||
<div className="w-full p-1 px-2.5 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User message */}
|
||||
<div className="flex justify-end">
|
||||
<div className="bg-muted rounded-md max-w-[60%] p-1 px-2.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assistant message */}
|
||||
<div className="flex justify-start">
|
||||
<div className="w-full p-1 px-2.5 space-y-2">
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User message */}
|
||||
<div className="flex justify-end">
|
||||
<div className="bg-muted rounded-md max-w-[60%] p-1 px-2.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useMemo } from "react";
|
||||
import { useRef, useCallback } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import { ChatInput } from "@multica/ui/components/chat-input";
|
||||
import { MemoizedMarkdown } from "@multica/ui/components/markdown";
|
||||
import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown";
|
||||
import { toast } from "@multica/ui/components/ui/sonner";
|
||||
import {
|
||||
useHubStore,
|
||||
useMessagesStore,
|
||||
useGatewayStore,
|
||||
useDeviceId,
|
||||
parseConnectionCode,
|
||||
saveConnection,
|
||||
} from "@multica/store";
|
||||
import { useConnectionStore, useMessagesStore, useAutoConnect } from "@multica/store";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { ConnectPrompt } from "./connect-prompt";
|
||||
import { MessageList } from "./message-list";
|
||||
import { ChatSkeleton } from "./chat-skeleton";
|
||||
|
||||
export function Chat() {
|
||||
const deviceId = useDeviceId()
|
||||
const activeAgentId = useHubStore((s) => s.activeAgentId)
|
||||
const gwState = useGatewayStore((s) => s.connectionState)
|
||||
const hubId = useGatewayStore((s) => s.hubId)
|
||||
const { loading } = useAutoConnect()
|
||||
|
||||
const agentId = useConnectionStore((s) => s.agentId)
|
||||
const gwState = useConnectionStore((s) => s.connectionState)
|
||||
const hubId = useConnectionStore((s) => s.hubId)
|
||||
|
||||
const messages = useMessagesStore((s) => s.messages)
|
||||
const streamingIds = useMessagesStore((s) => s.streamingIds)
|
||||
const filtered = useMemo(() => messages.filter(m => m.agentId === activeAgentId), [messages, activeAgentId])
|
||||
|
||||
const isConnected = gwState === "registered" && !!hubId && !!activeAgentId
|
||||
const [codeInput, setCodeInput] = useState("")
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
const trimmed = codeInput.trim()
|
||||
if (!trimmed || !deviceId) return
|
||||
try {
|
||||
const info = parseConnectionCode(trimmed)
|
||||
saveConnection(info)
|
||||
useGatewayStore.getState().connectWithCode(info, deviceId)
|
||||
setCodeInput("")
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
}
|
||||
}, [codeInput, deviceId])
|
||||
const isConnected = gwState === "registered" && !!hubId && !!agentId
|
||||
|
||||
const handleSend = useCallback((text: string) => {
|
||||
const { hubId } = useGatewayStore.getState()
|
||||
const agentId = useHubStore.getState().activeAgentId
|
||||
if (!hubId || !agentId) return
|
||||
useMessagesStore.getState().addUserMessage(text, agentId)
|
||||
useGatewayStore.getState().send(hubId, "message", { agentId, content: text })
|
||||
const { hubId, agentId, send, connectionState } = useConnectionStore.getState()
|
||||
if (connectionState !== "registered" || !hubId || !agentId) return
|
||||
useMessagesStore.getState().sendMessage(text, { hubId, agentId, send })
|
||||
}, [])
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
useConnectionStore.getState().disconnect()
|
||||
}, [])
|
||||
|
||||
const mainRef = useRef<HTMLElement>(null)
|
||||
|
|
@ -59,71 +38,30 @@ export function Chat() {
|
|||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full">
|
||||
{isConnected && (
|
||||
<div className="flex items-center justify-end px-4 py-1 max-w-4xl mx-auto w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
|
||||
{!isConnected ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Paste a connection code to start</p>
|
||||
{(gwState === "connecting" || gwState === "connected") && (
|
||||
<p className="text-xs text-muted-foreground/60 animate-pulse">Connecting...</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<Textarea
|
||||
value={codeInput}
|
||||
onChange={(e) => setCodeInput(e.target.value)}
|
||||
placeholder="Paste connection code here..."
|
||||
className="text-xs font-mono min-h-[100px] resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
handleConnect()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConnect}
|
||||
disabled={!codeInput.trim() || gwState === "connecting"}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
{loading ? (
|
||||
<ChatSkeleton />
|
||||
) : !isConnected ? (
|
||||
<ConnectPrompt />
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Send a message to start the conversation
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-6 space-y-6 max-w-4xl mx-auto">
|
||||
{filtered.map((msg) => {
|
||||
const isStreaming = streamingIds.has(msg.id)
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"flex",
|
||||
msg.role === "user" ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] p-1 px-2.5" : "w-full p-1 px-2.5"
|
||||
)}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<StreamingMarkdown content={msg.content} isStreaming={true} mode="minimal" />
|
||||
) : (
|
||||
<MemoizedMarkdown mode="minimal" id={msg.id}>
|
||||
{msg.content}
|
||||
</MemoizedMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<MessageList messages={messages} streamingIds={streamingIds} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
|
|
|
|||
62
packages/ui/src/components/connect-prompt.tsx
Normal file
62
packages/ui/src/components/connect-prompt.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import { toast } from "@multica/ui/components/ui/sonner";
|
||||
import {
|
||||
useConnectionStore,
|
||||
parseConnectionCode,
|
||||
saveConnection,
|
||||
} from "@multica/store";
|
||||
|
||||
export function ConnectPrompt() {
|
||||
const gwState = useConnectionStore((s) => s.connectionState)
|
||||
const [codeInput, setCodeInput] = useState("")
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
const trimmed = codeInput.trim()
|
||||
if (!trimmed) return
|
||||
try {
|
||||
const info = parseConnectionCode(trimmed)
|
||||
saveConnection(info)
|
||||
useConnectionStore.getState().connect(info)
|
||||
setCodeInput("")
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
}
|
||||
}, [codeInput])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Paste a connection code to start</p>
|
||||
{(gwState === "connecting" || gwState === "connected") && (
|
||||
<p className="text-xs text-muted-foreground/60 animate-pulse">Connecting...</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<Textarea
|
||||
value={codeInput}
|
||||
onChange={(e) => setCodeInput(e.target.value)}
|
||||
placeholder="Paste connection code here..."
|
||||
className="text-xs font-mono min-h-[100px] resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
handleConnect()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConnect}
|
||||
disabled={!codeInput.trim() || gwState === "connecting"}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
} from "@multica/ui/components/ui/sidebar"
|
||||
import { Button } from "@multica/ui/components/ui/button"
|
||||
import { Input } from "@multica/ui/components/ui/input"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { PlusSignIcon, Delete02Icon } from "@hugeicons/core-free-icons"
|
||||
import { useHubStore, useDeviceId, useGatewayStore } from "@multica/store"
|
||||
import { useHubInit } from "@multica/store"
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton"
|
||||
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
connected: "bg-green-500/60",
|
||||
loading: "bg-yellow-500/50 animate-pulse",
|
||||
error: "bg-red-500/60",
|
||||
idle: "bg-muted-foreground/50",
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
connected: "Connected",
|
||||
loading: "Connecting...",
|
||||
error: "Disconnected",
|
||||
idle: "Idle",
|
||||
}
|
||||
|
||||
export function HubSidebar() {
|
||||
useHubInit()
|
||||
|
||||
const status = useHubStore((s) => s.status)
|
||||
const hub = useHubStore((s) => s.hub)
|
||||
const agents = useHubStore((s) => s.agents)
|
||||
const activeAgentId = useHubStore((s) => s.activeAgentId)
|
||||
const createAgent = useHubStore((s) => s.createAgent)
|
||||
const deleteAgent = useHubStore((s) => s.deleteAgent)
|
||||
const setActiveAgentId = useHubStore((s) => s.setActiveAgentId)
|
||||
|
||||
const gwState = useGatewayStore((s) => s.connectionState)
|
||||
const hubId = useGatewayStore((s) => s.hubId)
|
||||
const hubs = useGatewayStore((s) => s.hubs)
|
||||
const setHubId = useGatewayStore((s) => s.setHubId)
|
||||
const listDevices = useGatewayStore((s) => s.listDevices)
|
||||
|
||||
const [hubIdInput, setHubIdInput] = useState("")
|
||||
const isRegistered = gwState === "registered"
|
||||
const needsHubSelection = isRegistered && !hubId
|
||||
|
||||
const handleConnect = () => {
|
||||
const id = hubIdInput.trim()
|
||||
if (!id) return
|
||||
setHubId(id)
|
||||
}
|
||||
|
||||
const handleDisconnect = () => {
|
||||
useGatewayStore.setState({ hubId: null })
|
||||
useHubStore.setState({ status: "idle", hub: null, agents: [], activeAgentId: null })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Hub</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
{!isRegistered ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-sm">
|
||||
<span className="size-2 rounded-full shrink-0 bg-yellow-500/50 animate-pulse" />
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
{gwState === "disconnected" ? "Connecting to Gateway..." : "Registering..."}
|
||||
</span>
|
||||
</div>
|
||||
) : needsHubSelection ? (
|
||||
<div className="px-2 space-y-2 py-1">
|
||||
{hubs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{hubs.map((h) => (
|
||||
<button
|
||||
key={h.deviceId}
|
||||
onClick={() => setHubId(h.deviceId)}
|
||||
className="w-full text-left px-2 py-1 rounded-md text-xs font-mono hover:bg-sidebar-accent hover:text-sidebar-accent-foreground cursor-pointer truncate"
|
||||
>
|
||||
{h.deviceId}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
value={hubIdInput}
|
||||
onChange={(e) => setHubIdInput(e.target.value)}
|
||||
placeholder="Or enter Hub ID..."
|
||||
className="h-7 text-xs font-mono"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleConnect}
|
||||
disabled={!hubIdInput.trim()}
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => listDevices()}
|
||||
className="text-xs"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-sm">
|
||||
<span className={`size-2 rounded-full shrink-0 ${STATUS_DOT[status] ?? STATUS_DOT.idle}`} />
|
||||
<span className="text-muted-foreground/70 text-xs">
|
||||
{STATUS_LABEL[status]}
|
||||
</span>
|
||||
</div>
|
||||
{status === "connected" && hub ? (
|
||||
<div className="px-2 text-xs text-muted-foreground/50 font-mono truncate">
|
||||
{hub.hubId}
|
||||
</div>
|
||||
) : (status === "idle" || status === "loading") ? (
|
||||
<Skeleton className="mx-2 h-3.5 w-32" />
|
||||
) : null}
|
||||
<div className="px-2 pt-1">
|
||||
<Button variant="outline" size="sm" onClick={handleDisconnect} className="w-full text-xs">
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{isRegistered && hubId && (status === "idle" || status === "loading") && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Agents</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="space-y-2 px-2 py-1">
|
||||
<Skeleton className="h-6 w-full rounded-md" />
|
||||
<Skeleton className="h-6 w-3/4 rounded-md" />
|
||||
<Skeleton className="h-6 w-5/6 rounded-md" />
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{status === "connected" && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Agents</SidebarGroupLabel>
|
||||
<SidebarGroupAction onClick={() => createAgent()} title="Create agent">
|
||||
<HugeiconsIcon icon={PlusSignIcon} />
|
||||
</SidebarGroupAction>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{agents.length === 0 && (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground/60">
|
||||
No agents
|
||||
</div>
|
||||
)}
|
||||
{agents.map(agent => (
|
||||
<SidebarMenuItem key={agent.id} className="group/agent-item">
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => setActiveAgentId(agent.id)}
|
||||
data-active={agent.id === activeAgentId || undefined}
|
||||
className="flex items-center w-full h-8 px-2 rounded-md cursor-pointer hover:bg-sidebar-accent hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-active:font-medium"
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-xs">
|
||||
{agent.id}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteAgent(agent.id)
|
||||
}}
|
||||
title="Delete agent"
|
||||
className="shrink-0 size-5 flex items-center justify-center rounded-md opacity-0 group-hover/agent-item:opacity-100 hover:bg-sidebar-accent-foreground/10 text-muted-foreground transition-opacity cursor-pointer"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={1.5} className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -16,8 +16,16 @@ interface Block {
|
|||
}
|
||||
|
||||
/**
|
||||
* Simple hash function for cache keys
|
||||
* Uses djb2 algorithm - fast and produces good distribution
|
||||
* djb2 hash (XOR variant) by Daniel J. Bernstein.
|
||||
* Used to generate stable React keys for completed content blocks.
|
||||
*
|
||||
* - 5381: empirically chosen initial value that produces fewer collisions
|
||||
* - (hash << 5) + hash: equivalent to hash * 33
|
||||
* - ^ charCode: XOR variant, favored by Bernstein over additive version
|
||||
* - >>> 0: convert to unsigned 32-bit integer
|
||||
*
|
||||
* Not cryptographic — just fast with good distribution for short strings.
|
||||
* @see http://www.cse.yorku.ca/~oz/hash.html
|
||||
*/
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 5381
|
||||
|
|
@ -164,7 +172,7 @@ export function StreamingMarkdown({
|
|||
}
|
||||
|
||||
const indicator = (
|
||||
<div className="flex items-center gap-2 py-1 text-muted-foreground">
|
||||
<div className="absolute bottom-1 left-6 flex items-center gap-2 py-1 text-muted-foreground">
|
||||
<Spinner className="text-xs" />
|
||||
<span className="text-xs">Generating...</span>
|
||||
</div>
|
||||
|
|
|
|||
44
packages/ui/src/components/message-list.tsx
Normal file
44
packages/ui/src/components/message-list.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { MemoizedMarkdown } from "@multica/ui/components/markdown";
|
||||
import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import type { Message } from "@multica/store";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[]
|
||||
streamingIds: Set<string>
|
||||
}
|
||||
|
||||
export function MessageList({ messages, streamingIds }: MessageListProps) {
|
||||
return (
|
||||
<div className="relative px-4 py-6 space-y-6 max-w-4xl mx-auto">
|
||||
{messages.map((msg) => {
|
||||
const isStreaming = streamingIds.has(msg.id)
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"flex",
|
||||
msg.role === "user" ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
msg.role === "user" ? "bg-muted rounded-md max-w-[60%] p-1 px-2.5" : "w-full p-1 px-2.5"
|
||||
)}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<StreamingMarkdown content={msg.content} isStreaming={true} mode="minimal" />
|
||||
) : (
|
||||
<MemoizedMarkdown mode="minimal" id={msg.id}>
|
||||
{msg.content}
|
||||
</MemoizedMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue