From e7521b4cb0e9819884c38e42a07571b75397d9fa Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:53:49 +0800 Subject: [PATCH 1/7] feat(web): replace sidebar with connection code flow - Add connection code parsing (JSON + base64) with localStorage persistence - Add connectWithCode() to gateway store for code-driven connections - Replace Sidebar with simple ConnectionBar (paste code / status / disconnect) - Simplify Chat component by removing header clutter - Auto-restore connection on page refresh from saved code Co-Authored-By: Claude Opus 4.5 --- apps/web/app/layout.tsx | 25 +--- packages/store/src/connection.ts | 76 +++++++++++ packages/store/src/gateway.ts | 113 ++++++++++------- packages/store/src/hub-init.ts | 27 ++-- packages/store/src/index.ts | 2 + packages/ui/src/components/chat.tsx | 64 +--------- packages/ui/src/components/connection-bar.tsx | 119 ++++++++++++++++++ 7 files changed, 289 insertions(+), 137 deletions(-) create mode 100644 packages/store/src/connection.ts create mode 100644 packages/ui/src/components/connection-bar.tsx diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 1daeebea..9d299154 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,22 +1,11 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono, Inter, Playfair_Display } from "next/font/google"; -import { useGatewayStore } from "@multica/store"; import "@multica/ui/globals.css"; -import { - SidebarProvider, - SidebarInset, -} from "@multica/ui/components/ui/sidebar"; -import { AppSidebar } from "@multica/ui/components/app-sidebar"; import { ThemeProvider } from "@multica/ui/components/theme-provider"; import { Toaster } from "@multica/ui/components/ui/sonner"; -import { HubSidebar } from "@multica/ui/components/hub-sidebar"; +import { ConnectionBar } from "@multica/ui/components/connection-bar"; import { ServiceWorkerRegister } from "./sw-register"; -const gatewayUrl = process.env.NEXT_PUBLIC_GATEWAY_URL; -if (gatewayUrl) { - useGatewayStore.getState().setGatewayUrl(gatewayUrl); -} - const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); const geistSans = Geist({ @@ -64,14 +53,10 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - - - - - -
{children}
-
-
+
+ +
{children}
+
diff --git a/packages/store/src/connection.ts b/packages/store/src/connection.ts new file mode 100644 index 00000000..38b628a1 --- /dev/null +++ b/packages/store/src/connection.ts @@ -0,0 +1,76 @@ +const STORAGE_KEY = "multica-connection" + +export interface ConnectionInfo { + type: "multica-connect" + gateway: string + hubId: string + agentId: string + token: string + expires: number +} + +function isConnectionInfo(obj: unknown): obj is ConnectionInfo { + if (typeof obj !== "object" || obj === null) return false + const o = obj as Record + return ( + o.type === "multica-connect" && + typeof o.gateway === "string" && + typeof o.hubId === "string" && + typeof o.agentId === "string" && + typeof o.token === "string" && + typeof o.expires === "number" + ) +} + +export function parseConnectionCode(input: string): ConnectionInfo { + const trimmed = input.trim() + + // Try JSON first + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + // Try base64 decode then JSON + try { + parsed = JSON.parse(atob(trimmed)) + } catch { + throw new Error("Invalid connection code") + } + } + + if (!isConnectionInfo(parsed)) { + throw new Error("Invalid connection code format") + } + + if (Date.now() > parsed.expires * 1000) { + throw new Error("Connection code has expired") + } + + return parsed +} + +export function saveConnection(info: ConnectionInfo): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(info)) +} + +export function loadConnection(): ConnectionInfo | null { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return null + + try { + const info = JSON.parse(raw) + if (!isConnectionInfo(info)) return null + if (Date.now() > info.expires * 1000) { + localStorage.removeItem(STORAGE_KEY) + return null + } + return info + } catch { + localStorage.removeItem(STORAGE_KEY) + return null + } +} + +export function clearConnection(): void { + localStorage.removeItem(STORAGE_KEY) +} diff --git a/packages/store/src/gateway.ts b/packages/store/src/gateway.ts index ed1d4814..8a4ec2cd 100644 --- a/packages/store/src/gateway.ts +++ b/packages/store/src/gateway.ts @@ -1,6 +1,7 @@ import { create } from "zustand" import { GatewayClient, StreamAction, extractTextFromEvent, type ConnectionState, type DeviceInfo, type SendErrorResponse, type StreamPayload, type StreamMessageEvent } from "@multica/sdk" import { useMessagesStore } from "./messages" +import type { ConnectionInfo } from "./connection" const DEFAULT_GATEWAY_URL = "http://localhost:3000" @@ -8,6 +9,8 @@ interface GatewayState { gatewayUrl: string connectionState: ConnectionState hubId: string | null + agentId: string | null + token: string | null hubs: DeviceInfo[] lastError: SendErrorResponse | null } @@ -15,6 +18,7 @@ interface GatewayState { interface GatewayActions { setGatewayUrl: (url: string) => void connect: (deviceId: string) => void + connectWithCode: (code: ConnectionInfo, deviceId: string) => void disconnect: () => void setHubId: (hubId: string) => void listDevices: () => Promise @@ -26,10 +30,57 @@ export type GatewayStore = GatewayState & GatewayActions let client: GatewayClient | null = null +function createClient(url: string, deviceId: string, set: (s: Partial) => void): GatewayClient { + return new GatewayClient({ + url, + deviceId, + deviceType: "client", + }) + .onStateChange((connectionState) => set({ connectionState })) + .onMessage((msg) => { + if (msg.action === StreamAction) { + const payload = msg.payload as StreamPayload + const store = useMessagesStore.getState() + const { event } = payload + + switch (event.type) { + case "message_start": { + store.startStream(payload.streamId, payload.agentId) + const text = extractTextFromEvent(event as StreamMessageEvent) + if (text) store.appendStream(payload.streamId, text) + break + } + case "message_update": { + const text = extractTextFromEvent(event as StreamMessageEvent) + store.appendStream(payload.streamId, text) + break + } + case "message_end": { + const text = extractTextFromEvent(event as StreamMessageEvent) + store.endStream(payload.streamId, text) + break + } + case "tool_execution_start": + case "tool_execution_end": + break + } + return + } + + 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 })) +} + export const useGatewayStore = create()((set, get) => ({ gatewayUrl: DEFAULT_GATEWAY_URL, connectionState: "disconnected", hubId: null, + agentId: null, + token: null, hubs: [], lastError: null, @@ -37,53 +88,25 @@ export const useGatewayStore = create()((set, get) => ({ connect: (deviceId) => { if (client) return + client = createClient(get().gatewayUrl, deviceId, set) + client.connect() + }, - client = new GatewayClient({ - url: get().gatewayUrl, - deviceId, - deviceType: "client", + connectWithCode: (code, deviceId) => { + // Disconnect existing connection if any + if (client) { + client.disconnect() + client = null + } + + set({ + gatewayUrl: code.gateway, + hubId: code.hubId, + agentId: code.agentId, + token: code.token, }) - .onStateChange((connectionState) => set({ connectionState })) - .onMessage((msg) => { - // Handle streaming messages (new protocol: payload.event is a raw AgentEvent) - if (msg.action === StreamAction) { - const payload = msg.payload as StreamPayload - const store = useMessagesStore.getState() - const { event } = payload - - switch (event.type) { - case "message_start": { - store.startStream(payload.streamId, payload.agentId) - const text = extractTextFromEvent(event as StreamMessageEvent) - if (text) store.appendStream(payload.streamId, text) - break - } - case "message_update": { - const text = extractTextFromEvent(event as StreamMessageEvent) - store.appendStream(payload.streamId, text) - break - } - case "message_end": { - const text = extractTextFromEvent(event as StreamMessageEvent) - store.endStream(payload.streamId, text) - break - } - case "tool_execution_start": - case "tool_execution_end": - // TODO: surface tool execution status in UI - break - } - return - } - - // Fallback: complete message handling - 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 = createClient(code.gateway, deviceId, set) client.connect() }, @@ -92,7 +115,7 @@ export const useGatewayStore = create()((set, get) => ({ client.disconnect() client = null } - set({ connectionState: "disconnected", hubId: null, hubs: [] }) + set({ connectionState: "disconnected", hubId: null, agentId: null, token: null, hubs: [] }) }, setHubId: (hubId) => set({ hubId }), diff --git a/packages/store/src/hub-init.ts b/packages/store/src/hub-init.ts index ca9de4da..a3843bdf 100644 --- a/packages/store/src/hub-init.ts +++ b/packages/store/src/hub-init.ts @@ -4,37 +4,38 @@ import { useEffect } from "react" import { useHubStore } from "./hub" import { useDeviceId } from "./device-id" import { useGatewayStore } from "./gateway" +import { loadConnection } from "./connection" export function useHubInit() { const deviceId = useDeviceId() const gwState = useGatewayStore((s) => s.connectionState) const hubId = useGatewayStore((s) => s.hubId) + const agentId = useGatewayStore((s) => s.agentId) const fetchHub = useHubStore((s) => s.fetchHub) const fetchAgents = useHubStore((s) => s.fetchAgents) + const setActiveAgentId = useHubStore((s) => s.setActiveAgentId) - // Auto-connect WS when deviceId is available + // Auto-connect from saved connection code useEffect(() => { - if (deviceId) { - useGatewayStore.getState().connect(deviceId) - return () => { useGatewayStore.getState().disconnect() } + if (!deviceId) return + const saved = loadConnection() + if (saved) { + useGatewayStore.getState().connectWithCode(saved, deviceId) } + return () => { useGatewayStore.getState().disconnect() } }, [deviceId]) - // Once WS is registered, discover available hubs - useEffect(() => { - if (gwState === "registered") { - useGatewayStore.getState().listDevices() - } - }, [gwState]) - - // Once hubId is set and WS is registered, fetch hub info and agents via RPC + // Once registered with a hub, fetch hub info and agents, set active agent useEffect(() => { if (gwState === "registered" && hubId) { fetchHub() fetchAgents() + if (agentId) { + setActiveAgentId(agentId) + } } if (gwState === "disconnected") { useHubStore.setState({ status: "idle", hub: null, agents: [], activeAgentId: null }) } - }, [gwState, hubId, fetchHub, fetchAgents]) + }, [gwState, hubId, agentId, fetchHub, fetchAgents, setActiveAgentId]) } diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 9b97057b..6b42afab 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -6,3 +6,5 @@ export { useMessagesStore } from "./messages" export type { Message, MessagesStore } from "./messages" export { useGatewayStore } from "./gateway" export type { GatewayStore } from "./gateway" +export { parseConnectionCode, saveConnection, loadConnection, clearConnection } from "./connection" +export type { ConnectionInfo } from "./connection" diff --git a/packages/ui/src/components/chat.tsx b/packages/ui/src/components/chat.tsx index 4363c7c0..2a4f88b1 100644 --- a/packages/ui/src/components/chat.tsx +++ b/packages/ui/src/components/chat.tsx @@ -1,28 +1,16 @@ "use client"; -import { useRef, useState, useCallback, useMemo } from "react"; -import { SidebarTrigger } from "@multica/ui/components/ui/sidebar"; -import { Badge } from "@multica/ui/components/ui/badge"; -import { Button } from "@multica/ui/components/ui/button"; +import { useRef, useCallback, useMemo } from "react"; import { ChatInput } from "@multica/ui/components/chat-input"; import { MemoizedMarkdown } from "@multica/ui/components/markdown"; import { StreamingMarkdown } from "@multica/ui/components/markdown/StreamingMarkdown"; import { HugeiconsIcon } from "@hugeicons/react"; -import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons"; -import { toast } from "@multica/ui/components/ui/sonner"; -import { useHubStore, useDeviceId, useMessagesStore, useGatewayStore } from "@multica/store"; +import { UserIcon } from "@hugeicons/core-free-icons"; +import { useHubStore, useMessagesStore, useGatewayStore } from "@multica/store"; import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll"; -import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { cn } from "@multica/ui/lib/utils"; -const STATE_VARIANT: Record = { - registered: "default", - connected: "secondary", - connecting: "secondary", - disconnected: "destructive", -} - export function Chat() { const activeAgentId = useHubStore((s) => s.activeAgentId) const gwState = useGatewayStore((s) => s.connectionState) @@ -41,59 +29,17 @@ export function Chat() { const canSend = gwState === "registered" && !!activeAgentId - const deviceId = useDeviceId() - const [deviceCopied, setDeviceCopied] = useState(false) - const handleCopyDevice = useCallback(async () => { - if (!deviceId) return - try { - await navigator.clipboard.writeText(deviceId) - setDeviceCopied(true) - toast.success("Device ID copied") - setTimeout(() => setDeviceCopied(false), 2000) - } catch { - toast.error("Failed to copy") - } - }, [deviceId]) - const mainRef = useRef(null) const fadeStyle = useScrollFade(mainRef) useAutoScroll(mainRef) return (
-
- - {deviceId ? ( - <> - - {deviceId} - - - - ) : ( - - )} - - {gwState} - -
-
{!activeAgentId ? (
- Select an agent to start chatting + Paste a connection code to start
) : filtered.length === 0 ? (
@@ -135,7 +81,7 @@ export function Chat() {
diff --git a/packages/ui/src/components/connection-bar.tsx b/packages/ui/src/components/connection-bar.tsx new file mode 100644 index 00000000..bbc356df --- /dev/null +++ b/packages/ui/src/components/connection-bar.tsx @@ -0,0 +1,119 @@ +"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... +
+ ) : ( +
+