From eef26d867532d5f4c0066b13a517f8bca9c1f441 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:07:57 +0800 Subject: [PATCH] refactor(frontend): complete migration of all hooks and components to packages - Create gateway store in @multica/store (WS connection independent of components) - Gateway auto-connects when hub is ready, messages handled internally - Move scroll-fade hook to @multica/ui/hooks - Move Chat component to @multica/ui/components - Add setConfig() call in web layout for URL injection - Delete all web-local hooks, components, and lib/config - Web app is now a pure shell: layout.tsx + page.tsx Co-Authored-By: Claude Opus 4.5 --- apps/web/app/hooks/use-gateway.ts | 44 -------------- apps/web/app/layout.tsx | 6 ++ apps/web/app/lib/config.ts | 2 - apps/web/app/page.tsx | 2 +- apps/web/package.json | 1 + packages/store/package.json | 1 + packages/store/src/gateway.ts | 57 +++++++++++++++++++ packages/store/src/hub-init.ts | 10 ++++ packages/store/src/index.ts | 2 + .../ui/src}/components/chat.tsx | 17 ++---- .../ui/src}/hooks/use-scroll-fade.ts | 0 pnpm-lock.yaml | 6 ++ 12 files changed, 88 insertions(+), 60 deletions(-) delete mode 100644 apps/web/app/hooks/use-gateway.ts delete mode 100644 apps/web/app/lib/config.ts create mode 100644 packages/store/src/gateway.ts rename {apps/web/app => packages/ui/src}/components/chat.tsx (89%) rename {apps/web/app => packages/ui/src}/hooks/use-scroll-fade.ts (100%) diff --git a/apps/web/app/hooks/use-gateway.ts b/apps/web/app/hooks/use-gateway.ts deleted file mode 100644 index 74169676..00000000 --- a/apps/web/app/hooks/use-gateway.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from "react" -import { GatewayClient, type ConnectionState, type RoutedMessage } from "@multica/sdk" -import { useDeviceId } from "@multica/store" -import { GATEWAY_URL } from "../lib/config" - -interface UseGatewayOptions { - onMessage?: (msg: RoutedMessage) => void -} - -export function useGateway(options?: UseGatewayOptions) { - const deviceId = useDeviceId() - const [state, setState] = useState("disconnected") - const clientRef = useRef(null) - const onMessageRef = useRef(options?.onMessage) - - useEffect(() => { - onMessageRef.current = options?.onMessage - }) - - useEffect(() => { - if (!deviceId) return - - const client = new GatewayClient({ - url: GATEWAY_URL, - deviceId, - deviceType: "client", - }) - .onStateChange(setState) - .onMessage(msg => onMessageRef.current?.(msg)) - - clientRef.current = client - client.connect() - return () => { client.disconnect() } - }, [deviceId]) - - const send = useCallback( - (to: string, action: string, payload: unknown) => { - clientRef.current?.send(to, action, payload) - }, - [] - ) - - return { state, send } -} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 89d78ef4..6fa0e8a5 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,12 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono, Inter, Playfair_Display } from "next/font/google"; +import { setConfig } from "@multica/fetch"; import "@multica/ui/globals.css"; + +setConfig({ + consoleUrl: process.env.NEXT_PUBLIC_CONSOLE_URL ?? "http://localhost:4000", + gatewayUrl: process.env.NEXT_PUBLIC_GATEWAY_URL ?? "http://localhost:3000", +}); import { SidebarProvider, SidebarInset, diff --git a/apps/web/app/lib/config.ts b/apps/web/app/lib/config.ts deleted file mode 100644 index 6f4f0c1f..00000000 --- a/apps/web/app/lib/config.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const GATEWAY_URL = process.env.NEXT_PUBLIC_GATEWAY_URL ?? "http://localhost:3000" -export const CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL ?? "http://localhost:4000" diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 0aabd23b..74c6c4a7 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,4 +1,4 @@ -import { Chat } from "./components/chat"; +import { Chat } from "@multica/ui/components/chat"; export default function Page() { return ; diff --git a/apps/web/package.json b/apps/web/package.json index 954a4008..09bac1ef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@multica/fetch": "workspace:*", "@multica/sdk": "workspace:*", "@multica/store": "workspace:*", "@multica/ui": "workspace:*", diff --git a/packages/store/package.json b/packages/store/package.json index c9518c97..1d0fb5c3 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@multica/fetch": "workspace:*", + "@multica/sdk": "workspace:*", "react": "catalog:", "uuid": "^13.0.0", "zustand": "catalog:" diff --git a/packages/store/src/gateway.ts b/packages/store/src/gateway.ts new file mode 100644 index 00000000..da5dd0d9 --- /dev/null +++ b/packages/store/src/gateway.ts @@ -0,0 +1,57 @@ +import { create } from "zustand" +import { GatewayClient, type ConnectionState, type SendErrorResponse } from "@multica/sdk" +import { getGatewayUrl } from "@multica/fetch" +import { useMessagesStore } from "./messages" + +interface GatewayState { + connectionState: ConnectionState + lastError: SendErrorResponse | null +} + +interface GatewayActions { + connect: (deviceId: string) => void + disconnect: () => void + send: (to: string, action: string, payload: unknown) => void +} + +export type GatewayStore = GatewayState & GatewayActions + +let client: GatewayClient | null = null + +export const useGatewayStore = create()((set) => ({ + connectionState: "disconnected", + lastError: null, + + connect: (deviceId) => { + if (client) return + + client = new GatewayClient({ + url: getGatewayUrl(), + deviceId, + deviceType: "client", + }) + .onStateChange((connectionState) => set({ connectionState })) + .onMessage((msg) => { + const payload = msg.payload as { agentId?: string; content?: string } + if (payload?.agentId && payload?.content) { + useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId) + } + }) + .onSendError((error) => set({ lastError: error })) + + client.connect() + }, + + disconnect: () => { + if (client) { + client.disconnect() + client = null + } + set({ connectionState: "disconnected" }) + }, + + send: (to, action, payload) => { + if (!client?.isRegistered) return + client.send(to, action, payload) + }, +})) diff --git a/packages/store/src/hub-init.ts b/packages/store/src/hub-init.ts index 2c070770..23cba21b 100644 --- a/packages/store/src/hub-init.ts +++ b/packages/store/src/hub-init.ts @@ -2,11 +2,14 @@ import { useEffect } from "react" import { useHubStore } from "./hub" +import { useDeviceId } from "./device-id" +import { useGatewayStore } from "./gateway" export function useHubInit() { const fetchHub = useHubStore((s) => s.fetchHub) const status = useHubStore((s) => s.status) const fetchAgents = useHubStore((s) => s.fetchAgents) + const deviceId = useDeviceId() useEffect(() => { fetchHub() }, [fetchHub]) useEffect(() => { @@ -16,4 +19,11 @@ export function useHubInit() { const id = setInterval(fetchHub, 30_000) return () => clearInterval(id) }, [fetchHub]) + + // Connect gateway when hub is ready and deviceId is available + useEffect(() => { + if (status === "connected" && deviceId) { + useGatewayStore.getState().connect(deviceId) + } + }, [status, deviceId]) } diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 32002eba..9b97057b 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -4,3 +4,5 @@ export { useHubInit } from "./hub-init" export { useDeviceId } from "./device-id" export { useMessagesStore } from "./messages" export type { Message, MessagesStore } from "./messages" +export { useGatewayStore } from "./gateway" +export type { GatewayStore } from "./gateway" diff --git a/apps/web/app/components/chat.tsx b/packages/ui/src/components/chat.tsx similarity index 89% rename from apps/web/app/components/chat.tsx rename to packages/ui/src/components/chat.tsx index ee114746..07048dcf 100644 --- a/apps/web/app/components/chat.tsx +++ b/packages/ui/src/components/chat.tsx @@ -9,9 +9,8 @@ import { MemoizedMarkdown } from "@multica/ui/components/markdown"; import { HugeiconsIcon } from "@hugeicons/react"; import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons"; import { toast } from "@multica/ui/components/ui/sonner"; -import { useGateway } from "../hooks/use-gateway"; -import { useHubStore, useDeviceId, useMessagesStore } from "@multica/store"; -import { useScrollFade } from "../hooks/use-scroll-fade"; +import { useHubStore, useDeviceId, useMessagesStore, useGatewayStore } from "@multica/store"; +import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; import { cn } from "@multica/ui/lib/utils"; const STATE_VARIANT: Record = { @@ -25,17 +24,9 @@ export function Chat() { const activeAgentId = useHubStore((s) => s.activeAgentId) const hub = useHubStore((s) => s.hub) const addUserMessage = useMessagesStore((s) => s.addUserMessage) - const addAssistantMessage = useMessagesStore((s) => s.addAssistantMessage) const messages = useMessagesStore((s) => s.messages) - - const { state: gwState, send } = useGateway({ - onMessage: (msg) => { - const payload = msg.payload as { agentId?: string; content?: string } - if (payload?.agentId && payload?.content) { - addAssistantMessage(payload.content, payload.agentId) - } - }, - }) + const gwState = useGatewayStore((s) => s.connectionState) + const send = useGatewayStore((s) => s.send) const handleSend = (text: string) => { if (!hub?.hubId || !activeAgentId) return diff --git a/apps/web/app/hooks/use-scroll-fade.ts b/packages/ui/src/hooks/use-scroll-fade.ts similarity index 100% rename from apps/web/app/hooks/use-scroll-fade.ts rename to packages/ui/src/hooks/use-scroll-fade.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d06f54bd..c5762ded 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,6 +211,9 @@ importers: '@hugeicons/react': specifier: ^1.1.4 version: 1.1.4(react@19.2.3) + '@multica/fetch': + specifier: workspace:* + version: link:../../packages/fetch '@multica/sdk': specifier: workspace:* version: link:../../packages/sdk @@ -282,6 +285,9 @@ importers: '@multica/fetch': specifier: workspace:* version: link:../fetch + '@multica/sdk': + specifier: workspace:* + version: link:../sdk react: specifier: 'catalog:' version: 19.2.3