From dacbfa9e3d6c0cc8e6bff2f36f13cb052170d0f1 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:31:24 +0800 Subject: [PATCH] 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 --- apps/desktop/src/pages/chat.tsx | 2 +- apps/desktop/src/pages/layout.tsx | 31 ++++- apps/web/app/app-header.tsx | 54 ++++++++ apps/web/app/layout.tsx | 9 +- apps/web/app/theme-toggle.tsx | 20 ++- packages/store/src/gateway.ts | 5 +- packages/store/src/hub-init.ts | 5 +- packages/store/src/hub.ts | 3 + packages/ui/src/components/chat.tsx | 44 +------ packages/ui/src/components/connection-bar.tsx | 119 ------------------ 10 files changed, 106 insertions(+), 186 deletions(-) create mode 100644 apps/web/app/app-header.tsx delete mode 100644 packages/ui/src/components/connection-bar.tsx diff --git a/apps/desktop/src/pages/chat.tsx b/apps/desktop/src/pages/chat.tsx index a4d6e0a5..52d406ed 100644 --- a/apps/desktop/src/pages/chat.tsx +++ b/apps/desktop/src/pages/chat.tsx @@ -1,5 +1,5 @@ import { Chat } from '@multica/ui/components/chat' export default function ChatPage() { - return + return } diff --git a/apps/desktop/src/pages/layout.tsx b/apps/desktop/src/pages/layout.tsx index 672ca01e..48d167ff 100644 --- a/apps/desktop/src/pages/layout.tsx +++ b/apps/desktop/src/pages/layout.tsx @@ -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 (
{/* Header */} @@ -28,9 +41,21 @@ export default function Layout() {
Multica
- +
+ {isConnected && ( + + )} + +
{/* Tabs */} diff --git a/apps/web/app/app-header.tsx b/apps/web/app/app-header.tsx new file mode 100644 index 00000000..e6052a26 --- /dev/null +++ b/apps/web/app/app-header.tsx @@ -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 ( + <> +
+
+
+ Multica + + Multica + +
+
+ + {isConnected && ( + + )} +
+
+
+ {children} + + ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f6c38402..e83481a0 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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 ( - -
{children}
+ +
{children}
+
diff --git a/apps/web/app/theme-toggle.tsx b/apps/web/app/theme-toggle.tsx index 60342b4b..70eb691e 100644 --- a/apps/web/app/theme-toggle.tsx +++ b/apps/web/app/theme-toggle.tsx @@ -9,16 +9,14 @@ export function ThemeToggle() { const { theme, setTheme } = useTheme(); return ( -
- -
+ ); } diff --git a/packages/store/src/gateway.ts b/packages/store/src/gateway.ts index 8a4ec2cd..009848d4 100644 --- a/packages/store/src/gateway.ts +++ b/packages/store/src/gateway.ts @@ -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()((set, get) => ({ connectionState: "disconnected", hubId: null, agentId: null, - token: null, hubs: [], lastError: null, @@ -103,7 +101,6 @@ export const useGatewayStore = create()((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()((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 }), diff --git a/packages/store/src/hub-init.ts b/packages/store/src/hub-init.ts index a3843bdf..d9735981 100644 --- a/packages/store/src/hub-init.ts +++ b/packages/store/src/hub-init.ts @@ -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]) } diff --git a/packages/store/src/hub.ts b/packages/store/src/hub.ts index 2bb0ef70..f06ca1fe 100644 --- a/packages/store/src/hub.ts +++ b/packages/store/src/hub.ts @@ -38,6 +38,7 @@ interface HubState { } interface HubActions { + reset: () => void setActiveAgentId: (id: string | null) => void fetchHub: () => Promise fetchAgents: () => Promise @@ -54,6 +55,8 @@ export const useHubStore = create()((set, get) => ({ agents: [], activeAgentId: null, + reset: () => set({ status: "idle", hub: null, agents: [], activeAgentId: null }), + setActiveAgentId: (id) => { set({ activeAgentId: id }) if (id) { diff --git a/packages/ui/src/components/chat.tsx b/packages/ui/src/components/chat.tsx index 575ccb8e..5848f510 100644 --- a/packages/ui/src/components/chat.tsx +++ b/packages/ui/src/components/chat.tsx @@ -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 (
- {/* Header */} - {showHeader && ( -
-
-
- Multica - - Multica - -
-
- {isConnected && ( - - )} -
-
-
- )} - - {/* Main */}
{!isConnected ? (
diff --git a/packages/ui/src/components/connection-bar.tsx b/packages/ui/src/components/connection-bar.tsx deleted file mode 100644 index bbc356df..00000000 --- a/packages/ui/src/components/connection-bar.tsx +++ /dev/null @@ -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 = { - 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 ( -
-
- Multica - - Multica - -
- -
- {isConnected ? ( -
-
- - - {hubStatus === "connected" ? "Connected" : "Connecting..."} - -
-
-
Hub: {hubId}
- {agentId &&
Agent: {agentId}
} -
- -
- ) : gwState === "connecting" || gwState === "connected" ? ( -
- - Connecting... -
- ) : ( -
-