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:
Naiyuan Qing 2026-02-04 10:12:51 +08:00
parent 162a86dff4
commit 62267aaf19
6 changed files with 195 additions and 303 deletions

View 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>
);
}

View file

@ -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>

View 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>
)
}

View file

@ -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>
)}
</>
)
}

View file

@ -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>

View 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>
)
}