refactor(ui): purify Chat component and move header to app layouts
- Remove all props from Chat (showHeader, headerActions) making it a zero-config pure chat component with only connection input, messages, and send functionality - Create AppHeader client component for web app with brand, theme toggle, disconnect button, and useHubInit - Add disconnect button to desktop layout header - Add reset() action to hub store to eliminate duplicated state reset - Remove unused token field from gateway store - Remove dead code: connection-bar.tsx - Guard handleConnect against empty deviceId race condition Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b6d7626239
commit
dacbfa9e3d
10 changed files with 106 additions and 186 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { Chat } from '@multica/ui/components/chat'
|
||||
|
||||
export default function ChatPage() {
|
||||
return <Chat showHeader={false} />
|
||||
return <Chat />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Outlet, NavLink, useLocation } from 'react-router-dom'
|
||||
import { useHubInit, useGatewayStore, useHubStore, clearConnection } from '@multica/store'
|
||||
import { Toaster } from '@multica/ui/components/ui/sonner'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
|
|
@ -19,8 +20,20 @@ const tabs = [
|
|||
]
|
||||
|
||||
export default function Layout() {
|
||||
useHubInit()
|
||||
const location = useLocation()
|
||||
|
||||
const gwState = useGatewayStore((s) => s.connectionState)
|
||||
const hubId = useGatewayStore((s) => s.hubId)
|
||||
const activeAgentId = useHubStore((s) => s.activeAgentId)
|
||||
const isConnected = gwState === 'registered' && !!hubId && !!activeAgentId
|
||||
|
||||
const handleDisconnect = () => {
|
||||
useGatewayStore.getState().disconnect()
|
||||
useHubStore.getState().reset()
|
||||
clearConnection()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
|
|
@ -28,9 +41,21 @@ export default function Layout() {
|
|||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">Multica</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon">
|
||||
<HugeiconsIcon icon={Settings02Icon} className="size-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{isConnected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon">
|
||||
<HugeiconsIcon icon={Settings02Icon} className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
|
|
|
|||
54
apps/web/app/app-header.tsx
Normal file
54
apps/web/app/app-header.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
useHubInit,
|
||||
useGatewayStore,
|
||||
useHubStore,
|
||||
clearConnection,
|
||||
} from "@multica/store";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
export function AppHeader({ children }: { children: React.ReactNode }) {
|
||||
useHubInit();
|
||||
|
||||
const gwState = useGatewayStore((s) => s.connectionState);
|
||||
const hubId = useGatewayStore((s) => s.hubId);
|
||||
const activeAgentId = useHubStore((s) => s.activeAgentId);
|
||||
const isConnected = gwState === "registered" && !!hubId && !!activeAgentId;
|
||||
|
||||
const handleDisconnect = () => {
|
||||
useGatewayStore.getState().disconnect();
|
||||
useHubStore.getState().reset();
|
||||
clearConnection();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
<div className="flex items-center justify-between px-4 py-2 max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src="/icon.png" alt="Multica" className="size-6 rounded-md" />
|
||||
<span className="text-sm tracking-wide font-[family-name:var(--font-brand)]">
|
||||
Multica
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
{isConnected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
|||
import { Geist, Geist_Mono, Inter, Playfair_Display } from "next/font/google";
|
||||
import "@multica/ui/globals.css";
|
||||
import { ThemeProvider } from "@multica/ui/components/theme-provider";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { AppHeader } from "./app-header";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { ServiceWorkerRegister } from "./sw-register";
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${playfair.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${playfair.variable} antialiased h-dvh flex flex-col`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
|
|
@ -53,8 +53,9 @@ export default function RootLayout({
|
|||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ThemeToggle />
|
||||
<div className="flex h-dvh overflow-hidden">{children}</div>
|
||||
<AppHeader>
|
||||
<div className="flex-1 overflow-hidden">{children}</div>
|
||||
</AppHeader>
|
||||
</ThemeProvider>
|
||||
<Toaster />
|
||||
<ServiceWorkerRegister />
|
||||
|
|
|
|||
|
|
@ -9,16 +9,14 @@ export function ThemeToggle() {
|
|||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="fixed top-2 right-2 z-50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="size-8 text-muted-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Sun01Icon} strokeWidth={1.5} className="size-4 dark:hidden" />
|
||||
<HugeiconsIcon icon={Moon01Icon} strokeWidth={1.5} className="size-4 hidden dark:block" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="size-8 text-muted-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Sun01Icon} strokeWidth={1.5} className="size-4 dark:hidden" />
|
||||
<HugeiconsIcon icon={Moon01Icon} strokeWidth={1.5} className="size-4 hidden dark:block" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ interface GatewayState {
|
|||
connectionState: ConnectionState
|
||||
hubId: string | null
|
||||
agentId: string | null
|
||||
token: string | null
|
||||
hubs: DeviceInfo[]
|
||||
lastError: SendErrorResponse | null
|
||||
}
|
||||
|
|
@ -80,7 +79,6 @@ export const useGatewayStore = create<GatewayStore>()((set, get) => ({
|
|||
connectionState: "disconnected",
|
||||
hubId: null,
|
||||
agentId: null,
|
||||
token: null,
|
||||
hubs: [],
|
||||
lastError: null,
|
||||
|
||||
|
|
@ -103,7 +101,6 @@ export const useGatewayStore = create<GatewayStore>()((set, get) => ({
|
|||
gatewayUrl: code.gateway,
|
||||
hubId: code.hubId,
|
||||
agentId: code.agentId,
|
||||
token: code.token,
|
||||
})
|
||||
|
||||
client = createClient(code.gateway, deviceId, set)
|
||||
|
|
@ -115,7 +112,7 @@ export const useGatewayStore = create<GatewayStore>()((set, get) => ({
|
|||
client.disconnect()
|
||||
client = null
|
||||
}
|
||||
set({ connectionState: "disconnected", hubId: null, agentId: null, token: null, hubs: [] })
|
||||
set({ connectionState: "disconnected", hubId: null, agentId: null, hubs: [] })
|
||||
},
|
||||
|
||||
setHubId: (hubId) => set({ hubId }),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export function useHubInit() {
|
|||
const gwState = useGatewayStore((s) => s.connectionState)
|
||||
const hubId = useGatewayStore((s) => s.hubId)
|
||||
const agentId = useGatewayStore((s) => s.agentId)
|
||||
const reset = useHubStore((s) => s.reset)
|
||||
const fetchHub = useHubStore((s) => s.fetchHub)
|
||||
const fetchAgents = useHubStore((s) => s.fetchAgents)
|
||||
const setActiveAgentId = useHubStore((s) => s.setActiveAgentId)
|
||||
|
|
@ -35,7 +36,7 @@ export function useHubInit() {
|
|||
}
|
||||
}
|
||||
if (gwState === "disconnected") {
|
||||
useHubStore.setState({ status: "idle", hub: null, agents: [], activeAgentId: null })
|
||||
reset()
|
||||
}
|
||||
}, [gwState, hubId, agentId, fetchHub, fetchAgents, setActiveAgentId])
|
||||
}, [gwState, hubId, agentId, reset, fetchHub, fetchAgents, setActiveAgentId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ interface HubState {
|
|||
}
|
||||
|
||||
interface HubActions {
|
||||
reset: () => void
|
||||
setActiveAgentId: (id: string | null) => void
|
||||
fetchHub: () => Promise<void>
|
||||
fetchAgents: () => Promise<void>
|
||||
|
|
@ -54,6 +55,8 @@ export const useHubStore = create<HubStore>()((set, get) => ({
|
|||
agents: [],
|
||||
activeAgentId: null,
|
||||
|
||||
reset: () => set({ status: "idle", hub: null, agents: [], activeAgentId: null }),
|
||||
|
||||
setActiveAgentId: (id) => {
|
||||
set({ activeAgentId: id })
|
||||
if (id) {
|
||||
|
|
|
|||
|
|
@ -11,22 +11,15 @@ import {
|
|||
useHubStore,
|
||||
useMessagesStore,
|
||||
useGatewayStore,
|
||||
useHubInit,
|
||||
useDeviceId,
|
||||
parseConnectionCode,
|
||||
saveConnection,
|
||||
clearConnection,
|
||||
} 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";
|
||||
|
||||
interface ChatProps {
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
export function Chat({ showHeader = true }: ChatProps) {
|
||||
useHubInit()
|
||||
export function Chat() {
|
||||
const deviceId = useDeviceId()
|
||||
const activeAgentId = useHubStore((s) => s.activeAgentId)
|
||||
const gwState = useGatewayStore((s) => s.connectionState)
|
||||
|
|
@ -41,7 +34,7 @@ export function Chat({ showHeader = true }: ChatProps) {
|
|||
|
||||
const handleConnect = useCallback(() => {
|
||||
const trimmed = codeInput.trim()
|
||||
if (!trimmed) return
|
||||
if (!trimmed || !deviceId) return
|
||||
try {
|
||||
const info = parseConnectionCode(trimmed)
|
||||
saveConnection(info)
|
||||
|
|
@ -52,12 +45,6 @@ export function Chat({ showHeader = true }: ChatProps) {
|
|||
}
|
||||
}, [codeInput, deviceId])
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
useGatewayStore.getState().disconnect()
|
||||
useHubStore.setState({ status: "idle", hub: null, agents: [], activeAgentId: null })
|
||||
clearConnection()
|
||||
}, [])
|
||||
|
||||
const handleSend = useCallback((text: string) => {
|
||||
const { hubId } = useGatewayStore.getState()
|
||||
const agentId = useHubStore.getState().activeAgentId
|
||||
|
|
@ -72,33 +59,6 @@ export function Chat({ showHeader = true }: ChatProps) {
|
|||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full">
|
||||
{/* Header */}
|
||||
{showHeader && (
|
||||
<header>
|
||||
<div className="flex items-center justify-between px-4 py-2 max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src="/icon.png" alt="Multica" className="size-6 rounded-md" />
|
||||
<span className="text-sm tracking-wide font-[family-name:var(--font-brand)]">
|
||||
Multica
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isConnected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Main */}
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState } 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 {
|
||||
useGatewayStore,
|
||||
useHubStore,
|
||||
useDeviceId,
|
||||
useHubInit,
|
||||
parseConnectionCode,
|
||||
saveConnection,
|
||||
clearConnection,
|
||||
} from "@multica/store"
|
||||
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
registered: "bg-green-500",
|
||||
connected: "bg-yellow-500 animate-pulse",
|
||||
connecting: "bg-yellow-500 animate-pulse",
|
||||
disconnected: "bg-red-500",
|
||||
}
|
||||
|
||||
export function ConnectionBar() {
|
||||
useHubInit()
|
||||
|
||||
const deviceId = useDeviceId()
|
||||
const gwState = useGatewayStore((s) => s.connectionState)
|
||||
const hubId = useGatewayStore((s) => s.hubId)
|
||||
const agentId = useGatewayStore((s) => s.agentId)
|
||||
const hubStatus = useHubStore((s) => s.status)
|
||||
|
||||
const isConnected = gwState === "registered" && hubId
|
||||
const [codeInput, setCodeInput] = useState("")
|
||||
|
||||
const handleConnect = () => {
|
||||
const trimmed = codeInput.trim()
|
||||
if (!trimmed) return
|
||||
try {
|
||||
const info = parseConnectionCode(trimmed)
|
||||
saveConnection(info)
|
||||
useGatewayStore.getState().connectWithCode(info, deviceId)
|
||||
setCodeInput("")
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = () => {
|
||||
useGatewayStore.getState().disconnect()
|
||||
useHubStore.setState({ status: "idle", hub: null, agents: [], activeAgentId: null })
|
||||
clearConnection()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-64 shrink-0 border-r flex flex-col h-dvh">
|
||||
<div className="flex items-center gap-2.5 p-4 pb-2">
|
||||
<img src="/icon.png" alt="Multica" className="size-7 rounded-md" />
|
||||
<span className="text-sm tracking-wide font-[family-name:var(--font-brand)]">
|
||||
Multica
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4 pt-2">
|
||||
{isConnected ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`size-2 rounded-full shrink-0 ${STATUS_DOT[gwState]}`} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hubStatus === "connected" ? "Connected" : "Connecting..."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-muted-foreground/70 font-mono">
|
||||
<div className="truncate" title={hubId}>Hub: {hubId}</div>
|
||||
{agentId && <div className="truncate" title={agentId}>Agent: {agentId}</div>}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
) : gwState === "connecting" || gwState === "connected" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full shrink-0 bg-yellow-500 animate-pulse" />
|
||||
<span className="text-xs text-muted-foreground">Connecting...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={codeInput}
|
||||
onChange={(e) => setCodeInput(e.target.value)}
|
||||
placeholder="Paste connection code..."
|
||||
className="text-xs font-mono min-h-[80px] resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
handleConnect()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleConnect}
|
||||
disabled={!codeInput.trim()}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue