diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c7509b45..00f42d30 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,13 +13,15 @@ "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/react": "^1.1.4", "@multica/sdk": "workspace:*", + "@multica/store": "workspace:*", "@multica/ui": "workspace:*", "qrcode.react": "^4.2.0", "react": "catalog:", "react-dom": "catalog:", "react-router-dom": "^7.13.0", "socket.io-client": "^4.8.3", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "zustand": "catalog:" }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", diff --git a/apps/desktop/src/pages/chat.tsx b/apps/desktop/src/pages/chat.tsx index f3495285..52d406ed 100644 --- a/apps/desktop/src/pages/chat.tsx +++ b/apps/desktop/src/pages/chat.tsx @@ -1,15 +1,5 @@ -import { useNavigate } from 'react-router-dom' -import { Button } from '@multica/ui/components/ui/button' +import { Chat } from '@multica/ui/components/chat' export default function ChatPage() { - const navigate = useNavigate() - - return ( -
-

Chat

- -
- ) + return } diff --git a/apps/desktop/src/pages/layout.tsx b/apps/desktop/src/pages/layout.tsx index e0922413..48d167ff 100644 --- a/apps/desktop/src/pages/layout.tsx +++ b/apps/desktop/src/pages/layout.tsx @@ -1,4 +1,6 @@ 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' import { @@ -18,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 */} @@ -27,9 +41,21 @@ export default function Layout() {
Multica
- +
+ {isConnected && ( + + )} + +
{/* Tabs */} @@ -55,9 +81,10 @@ export default function Layout() { {/* Content */} -
+
+
) } 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 1daeebea..e83481a0 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 { AppHeader } from "./app-header"; import { Toaster } from "@multica/ui/components/ui/sonner"; -import { HubSidebar } from "@multica/ui/components/hub-sidebar"; 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({ @@ -56,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 new file mode 100644 index 00000000..70eb691e --- /dev/null +++ b/apps/web/app/theme-toggle.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Button } from "@multica/ui/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Sun01Icon, Moon01Icon } from "@hugeicons/core-free-icons"; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + return ( + + ); +} diff --git a/apps/web/package.json b/apps/web/package.json index 954a4008..95b1b67c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/react": "^1.1.4", "next": "16.1.6", + "next-themes": "^0.4.6", "react": "catalog:", "react-dom": "catalog:" }, diff --git a/packages/store/src/connection.ts b/packages/store/src/connection.ts new file mode 100644 index 00000000..42e4f067 --- /dev/null +++ b/packages/store/src/connection.ts @@ -0,0 +1,116 @@ +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" + ) +} + +// Parse multica://connect?gateway=...&hub=...&agent=...&token=...&exp=... URL format +// Uses string prefix + URLSearchParams to avoid cross-engine URL hostname differences +function parseConnectionUrl(input: string): ConnectionInfo | null { + const prefix = "multica://connect?" + if (!input.startsWith(prefix)) return null + try { + const params = new URLSearchParams(input.slice(prefix.length)) + const gateway = params.get("gateway") + const hubId = params.get("hub") + const agentId = params.get("agent") + const token = params.get("token") + const exp = params.get("exp") + if (!gateway || !hubId || !agentId || !token || !exp) return null + return { + type: "multica-connect", + gateway, + hubId, + agentId, + token, + expires: Number(exp), + } + } catch { + return null + } +} + +function isExpired(expires: number): boolean { + // Desktop generates expires as millisecond timestamp (Date.now() + seconds * 1000) + return Date.now() > expires +} + +export function parseConnectionCode(input: string): ConnectionInfo { + const trimmed = input.trim() + + // Try multica:// URL format first (desktop "Copy Link" output) + const fromUrl = parseConnectionUrl(trimmed) + if (fromUrl) { + if (isExpired(fromUrl.expires)) { + throw new Error("Connection code has expired") + } + return fromUrl + } + + // Try JSON (QR code scan output) + 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 (isExpired(parsed.expires)) { + 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 (isExpired(info.expires)) { + 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..009848d4 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,7 @@ interface GatewayState { gatewayUrl: string connectionState: ConnectionState hubId: string | null + agentId: string | null hubs: DeviceInfo[] lastError: SendErrorResponse | null } @@ -15,6 +17,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 +29,56 @@ 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, hubs: [], lastError: null, @@ -37,53 +86,24 @@ 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, }) - .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 +112,7 @@ export const useGatewayStore = create()((set, get) => ({ client.disconnect() client = null } - set({ connectionState: "disconnected", hubId: 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 ca9de4da..d9735981 100644 --- a/packages/store/src/hub-init.ts +++ b/packages/store/src/hub-init.ts @@ -4,37 +4,39 @@ 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 reset = useHubStore((s) => s.reset) 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 }) + reset() } - }, [gwState, hubId, fetchHub, fetchAgents]) + }, [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/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..5848f510 100644 --- a/packages/ui/src/components/chat.tsx +++ b/packages/ui/src/components/chat.tsx @@ -1,36 +1,50 @@ "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 { 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 { 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 { + useHubStore, + useMessagesStore, + useGatewayStore, + useDeviceId, + parseConnectionCode, + saveConnection, +} 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 deviceId = useDeviceId() const activeAgentId = useHubStore((s) => s.activeAgentId) const gwState = useGatewayStore((s) => s.connectionState) + const hubId = useGatewayStore((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 handleSend = useCallback((text: string) => { const { hubId } = useGatewayStore.getState() const agentId = useHubStore.getState().activeAgentId @@ -39,61 +53,43 @@ export function Chat() { useGatewayStore.getState().send(hubId, "message", { agentId, content: text }) }, []) - 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 + {!isConnected ? ( +
+
+

Paste a connection code to start

+ {(gwState === "connecting" || gwState === "connected") && ( +

Connecting...

+ )} +
+
+