From 7a21686505ef4359a3e6652e5ad3d08fd74749ca Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:50:47 +0800 Subject: [PATCH] refactor(hooks,ui): extract useGatewayChat hook and update shared components Move gateway-specific chat logic into dedicated useGatewayChat hook. useChat remains a pure state hook with no IO. Update ChatView props, remove legacy chat.tsx and connect-prompt.tsx. Co-Authored-By: Claude Opus 4.5 --- packages/hooks/src/index.ts | 2 + packages/hooks/src/use-chat.ts | 405 +++++++----------- packages/hooks/src/use-gateway-chat.ts | 87 ++++ packages/hooks/src/use-gateway-connection.ts | 94 ++-- packages/ui/src/components/chat-view.tsx | 38 +- packages/ui/src/components/chat.tsx | 134 ------ packages/ui/src/components/connect-prompt.tsx | 269 ------------ packages/ui/src/components/device-pairing.tsx | 4 +- packages/ui/src/components/message-list.tsx | 2 +- 9 files changed, 320 insertions(+), 715 deletions(-) create mode 100644 packages/hooks/src/use-gateway-chat.ts delete mode 100644 packages/ui/src/components/chat.tsx delete mode 100644 packages/ui/src/components/connect-prompt.tsx diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index eb422289..e9a61966 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -13,3 +13,5 @@ export type { PendingApproval, UseChatReturn, } from "./use-chat"; + +export { useGatewayChat } from "./use-gateway-chat"; diff --git a/packages/hooks/src/use-chat.ts b/packages/hooks/src/use-chat.ts index f195eedb..acb6691b 100644 --- a/packages/hooks/src/use-chat.ts +++ b/packages/hooks/src/use-chat.ts @@ -1,17 +1,14 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useCallback } from "react"; import { v7 as uuidv7 } from "uuid"; import { - type GatewayClient, type ContentBlock, type AgentEvent, type StreamPayload, - type GetAgentMessagesResult, + type AgentMessageItem, type ExecApprovalRequestPayload, type ApprovalDecision, - StreamAction, - ExecApprovalRequestAction, } from "@multica/sdk"; export type ToolStatus = "running" | "success" | "error" | "interrupted"; @@ -29,32 +26,18 @@ export interface Message { isError?: boolean; } -interface UseChatOptions { - client: GatewayClient; - hubId: string; - agentId: string; -} - export interface ChatError { code: string; message: string; } export interface PendingApproval extends ExecApprovalRequestPayload { - /** Timestamp when the request was received (for ordering) */ receivedAt: number; } -export interface UseChatReturn { - messages: Message[]; - streamingIds: Set; - isLoading: boolean; - isLoadingHistory: boolean; - error: ChatError | null; - pendingApprovals: PendingApproval[]; - sendMessage: (text: string) => void; - resolveApproval: (approvalId: string, decision: ApprovalDecision) => void; -} +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- function toContentBlocks(content: string | ContentBlock[]): ContentBlock[] { if (typeof content === "string") { @@ -72,258 +55,178 @@ function extractContent(event: AgentEvent): ContentBlock[] { return Array.isArray(content) ? (content as ContentBlock[]) : []; } -export function useChat({ client, hubId, agentId }: UseChatOptions): UseChatReturn { +// --------------------------------------------------------------------------- +// useChat — pure state hook, no IO, no side effects +// --------------------------------------------------------------------------- + +export function useChat() { const [messages, setMessages] = useState([]); const [streamingIds, setStreamingIds] = useState>(new Set()); - const [isLoading, setIsLoading] = useState(false); - const [isLoadingHistory, setIsLoadingHistory] = useState(true); - const [error, setError] = useState(null); const [pendingApprovals, setPendingApprovals] = useState([]); - // Keep a ref for use inside callbacks (avoids stale closures) - const messagesRef = useRef(messages); - messagesRef.current = messages; + const [error, setError] = useState(null); - // Fetch history on mount - useEffect(() => { - async function fetchHistory() { - try { - const result = await client.request( - hubId, - "getAgentMessages", - { agentId, limit: 200 }, - ); + const isStreaming = streamingIds.size > 0; - // Build toolCallId → args lookup from assistant tool_use blocks - const toolCallArgsMap = new Map }>(); - for (const m of result.messages) { - if (m.role === "assistant") { - for (const block of m.content) { - if (block.type === "toolCall") { - toolCallArgsMap.set(block.id, { name: block.name, args: block.arguments }); - } - } + /** Load history: convert raw AgentMessageItem[] → Message[] */ + const setHistory = useCallback((raw: AgentMessageItem[], agentId: string) => { + const toolCallArgsMap = new Map }>(); + for (const m of raw) { + if (m.role === "assistant") { + for (const block of m.content) { + if (block.type === "toolCall") { + toolCallArgsMap.set(block.id, { name: block.name, args: block.arguments }); } } - - const loaded: Message[] = []; - for (const m of result.messages) { - if (m.role === "user") { - loaded.push({ - id: uuidv7(), - role: "user", - content: toContentBlocks(m.content), - agentId, - }); - } else if (m.role === "assistant") { - loaded.push({ - id: uuidv7(), - role: "assistant", - content: toContentBlocks(m.content), - agentId, - stopReason: m.stopReason, - }); - } else if (m.role === "toolResult") { - const callInfo = toolCallArgsMap.get(m.toolCallId); - loaded.push({ - id: uuidv7(), - role: "toolResult", - content: toContentBlocks(m.content), - agentId, - toolCallId: m.toolCallId, - toolName: m.toolName, - toolArgs: callInfo?.args, - toolStatus: m.isError ? "error" : "success", - isError: m.isError, - }); - } - } - - if (loaded.length > 0) { - setMessages(loaded); - } - } catch { - // History fetch is best-effort - } finally { - setIsLoadingHistory(false); } } - fetchHistory(); - }, [client, hubId, agentId]); - - // Listen for streaming events - useEffect(() => { - client.onMessage((msg) => { - if (msg.action === StreamAction) { - const payload = msg.payload as StreamPayload; - const { event } = payload; - - switch (event.type) { - case "message_start": { - const newMsg: Message = { - id: payload.streamId, - role: "assistant", - content: [], - agentId: payload.agentId, - }; - const content = extractContent(event); - if (content.length) newMsg.content = content; - - setMessages((prev) => [...prev, newMsg]); - setStreamingIds((prev) => new Set(prev).add(payload.streamId)); - setIsLoading(true); - break; - } - case "message_update": { - const content = extractContent(event); - setMessages((prev) => - prev.map((m) => - m.id === payload.streamId ? { ...m, content } : m, - ), - ); - break; - } - case "message_end": { - const content = extractContent(event); - const stopReason = - "message" in event - ? (event.message as { stopReason?: string })?.stopReason - : undefined; - - setMessages((prev) => - prev.map((m) => { - if (m.id === payload.streamId) return { ...m, content, stopReason }; - // Interrupt running tools belonging to the same agent - if ( - m.role === "toolResult" && - m.toolStatus === "running" && - m.agentId === payload.agentId - ) { - return { ...m, toolStatus: "interrupted" as ToolStatus }; - } - return m; - }), - ); - setStreamingIds((prev) => { - const next = new Set(prev); - next.delete(payload.streamId); - return next; - }); - setIsLoading(false); - break; - } - case "tool_execution_start": { - const toolMsg: Message = { - id: uuidv7(), - role: "toolResult", - content: [], - agentId: payload.agentId, - toolCallId: event.toolCallId, - toolName: event.toolName, - toolArgs: event.args as Record | undefined, - toolStatus: "running", - isError: false, - }; - setMessages((prev) => [...prev, toolMsg]); - break; - } - case "tool_execution_end": { - setMessages((prev) => - prev.map((m) => - m.role === "toolResult" && m.toolCallId === event.toolCallId - ? { - ...m, - toolStatus: (event.isError ? "error" : "success") as ToolStatus, - isError: event.isError ?? false, - content: - event.result != null - ? [ - { - type: "text" as const, - text: - typeof event.result === "string" - ? event.result - : JSON.stringify(event.result), - }, - ] - : [], - } - : m, - ), - ); - break; - } - case "tool_execution_update": - break; - } - return; + const loaded: Message[] = []; + for (const m of raw) { + if (m.role === "user") { + loaded.push({ id: uuidv7(), role: "user", content: toContentBlocks(m.content), agentId }); + } else if (m.role === "assistant") { + loaded.push({ id: uuidv7(), role: "assistant", content: toContentBlocks(m.content), agentId, stopReason: m.stopReason }); + } else if (m.role === "toolResult") { + const callInfo = toolCallArgsMap.get(m.toolCallId); + loaded.push({ + id: uuidv7(), + role: "toolResult", + content: toContentBlocks(m.content), + agentId, + toolCallId: m.toolCallId, + toolName: m.toolName, + toolArgs: callInfo?.args, + toolStatus: m.isError ? "error" : "success", + isError: m.isError, + }); } + } - // Exec approval request from Hub - if (msg.action === ExecApprovalRequestAction) { - const payload = msg.payload as ExecApprovalRequestPayload; - setPendingApprovals((prev) => [...prev, { ...payload, receivedAt: Date.now() }]); - return; + setMessages(loaded); + }, []); + + /** Add a user message */ + const addUserMessage = useCallback((text: string, agentId: string) => { + setMessages((prev) => [ + ...prev, + { id: uuidv7(), role: "user", content: [{ type: "text", text }], agentId }, + ]); + }, []); + + /** Process a StreamPayload → update messages + streamingIds */ + const handleStream = useCallback((payload: StreamPayload) => { + const { event } = payload; + + switch (event.type) { + case "message_start": { + const newMsg: Message = { + id: payload.streamId, + role: "assistant", + content: [], + agentId: payload.agentId, + }; + const content = extractContent(event); + if (content.length) newMsg.content = content; + + setMessages((prev) => [...prev, newMsg]); + setStreamingIds((prev) => new Set(prev).add(payload.streamId)); + break; } - - // Error from Hub (e.g. UNAUTHORIZED) - if (msg.action === "error") { - const errPayload = msg.payload as { code: string; message: string }; - setError({ code: errPayload.code, message: errPayload.message }); - return; + case "message_update": { + const content = extractContent(event); + setMessages((prev) => + prev.map((m) => (m.id === payload.streamId ? { ...m, content } : m)), + ); + break; } + case "message_end": { + const content = extractContent(event); + const stopReason = + "message" in event + ? (event.message as { stopReason?: string })?.stopReason + : undefined; - // Direct (non-streaming) message - const payload = msg.payload as { agentId?: string; content?: string }; - if (payload?.agentId && payload?.content) { + setMessages((prev) => + prev.map((m) => { + if (m.id === payload.streamId) return { ...m, content, stopReason }; + if (m.role === "toolResult" && m.toolStatus === "running" && m.agentId === payload.agentId) { + return { ...m, toolStatus: "interrupted" as ToolStatus }; + } + return m; + }), + ); + setStreamingIds((prev) => { + const next = new Set(prev); + next.delete(payload.streamId); + return next; + }); + break; + } + case "tool_execution_start": { setMessages((prev) => [ ...prev, { id: uuidv7(), - role: "assistant", - content: [{ type: "text", text: payload.content! }], - agentId: payload.agentId!, + role: "toolResult", + content: [], + agentId: payload.agentId, + toolCallId: event.toolCallId, + toolName: event.toolName, + toolArgs: event.args as Record | undefined, + toolStatus: "running", + isError: false, }, ]); + break; } - }); + case "tool_execution_end": { + setMessages((prev) => + prev.map((m) => + m.role === "toolResult" && m.toolCallId === event.toolCallId + ? { + ...m, + toolStatus: (event.isError ? "error" : "success") as ToolStatus, + isError: event.isError ?? false, + content: + event.result != null + ? [{ type: "text" as const, text: typeof event.result === "string" ? event.result : JSON.stringify(event.result) }] + : [], + } + : m, + ), + ); + break; + } + case "tool_execution_update": + break; + } + }, []); - return () => { - // Clear onMessage when unmounting - client.onMessage(() => {}); - }; - }, [client, agentId]); + /** Add pending approval */ + const addApproval = useCallback((payload: ExecApprovalRequestPayload) => { + setPendingApprovals((prev) => [...prev, { ...payload, receivedAt: Date.now() }]); + }, []); - const resolveApproval = useCallback( - (approvalId: string, decision: ApprovalDecision) => { - setPendingApprovals((prev) => prev.filter((a) => a.approvalId !== approvalId)); - client.request(hubId, "resolveExecApproval", { approvalId, decision }).catch(() => { - // Best-effort — approval may have already expired - }); - }, - [client, hubId], - ); + /** Remove pending approval */ + const removeApproval = useCallback((approvalId: string) => { + setPendingApprovals((prev) => prev.filter((a) => a.approvalId !== approvalId)); + }, []); - const sendMessage = useCallback( - (text: string) => { - const trimmed = text.trim(); - if (!trimmed) return; - - setMessages((prev) => [ - ...prev, - { - id: uuidv7(), - role: "user", - content: [{ type: "text", text: trimmed }], - agentId, - }, - ]); - - client.send(hubId, "message", { agentId, content: trimmed }); - setIsLoading(true); - }, - [client, hubId, agentId], - ); - - return { messages, streamingIds, isLoading, isLoadingHistory, error, pendingApprovals, sendMessage, resolveApproval }; + return { + // Rendering state + messages, + streamingIds, + isStreaming, + pendingApprovals, + error, + // State control (for transport layer to call) + setError, + setHistory, + addUserMessage, + handleStream, + addApproval, + removeApproval, + }; } + +export type UseChatReturn = ReturnType; diff --git a/packages/hooks/src/use-gateway-chat.ts b/packages/hooks/src/use-gateway-chat.ts new file mode 100644 index 00000000..ec085a10 --- /dev/null +++ b/packages/hooks/src/use-gateway-chat.ts @@ -0,0 +1,87 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + type GatewayClient, + type StreamPayload, + type GetAgentMessagesResult, + type ExecApprovalRequestPayload, + type ApprovalDecision, + StreamAction, + ExecApprovalRequestAction, +} from "@multica/sdk"; +import { useChat } from "./use-chat"; + +interface UseGatewayChatOptions { + client: GatewayClient; + hubId: string; + agentId: string; +} + +export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions) { + const chat = useChat(); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingHistory, setIsLoadingHistory] = useState(true); + + // Fetch history + useEffect(() => { + client + .request(hubId, "getAgentMessages", { agentId, limit: 200 }) + .then((result) => chat.setHistory(result.messages, agentId)) + .catch(() => {}) + .finally(() => setIsLoadingHistory(false)); + }, [client, hubId, agentId]); + + // Subscribe to events + useEffect(() => { + client.onMessage((msg) => { + if (msg.action === StreamAction) { + const payload = msg.payload as StreamPayload; + chat.handleStream(payload); + if (payload.event.type === "message_start") setIsLoading(true); + if (payload.event.type === "message_end") setIsLoading(false); + return; + } + if (msg.action === ExecApprovalRequestAction) { + chat.addApproval(msg.payload as ExecApprovalRequestPayload); + return; + } + if (msg.action === "error") { + chat.setError(msg.payload as { code: string; message: string }); + return; + } + }); + return () => { client.onMessage(() => {}); }; + }, [client]); + + const sendMessage = useCallback( + (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + chat.addUserMessage(trimmed, agentId); + chat.setError(null); + client.send(hubId, "message", { agentId, content: trimmed }); + setIsLoading(true); + }, + [client, hubId, agentId], + ); + + const resolveApproval = useCallback( + (approvalId: string, decision: ApprovalDecision) => { + chat.removeApproval(approvalId); + client.request(hubId, "resolveExecApproval", { approvalId, decision }).catch(() => {}); + }, + [client, hubId], + ); + + return { + messages: chat.messages, + streamingIds: chat.streamingIds, + isLoading, + isLoadingHistory, + error: chat.error, + pendingApprovals: chat.pendingApprovals, + sendMessage, + resolveApproval, + }; +} diff --git a/packages/hooks/src/use-gateway-connection.ts b/packages/hooks/src/use-gateway-connection.ts index 211dd1b6..f77e043d 100644 --- a/packages/hooks/src/use-gateway-connection.ts +++ b/packages/hooks/src/use-gateway-connection.ts @@ -72,50 +72,58 @@ export function useGatewayConnection(): UseGatewayConnectionReturn { const connectToGateway = useCallback( (id: ConnectionIdentity, token?: string) => { + const doConnect = () => { + disconnectingRef.current = false; + setPageState("connecting"); + setError(null); + + const deviceId = getDeviceId(); + + const client = new GatewayClient({ + url: id.gateway, + deviceId, + deviceType: "client", + hubId: id.hubId, + ...(token ? { token } : {}), + }) + .onStateChange((state: ConnectionState) => { + console.log("[GatewayConnection] state:", state); + if (disconnectingRef.current) return; + setConnectionState(state); + if (state === "registered") { + saveIdentity(id); + setIdentity(id); + setPageState("connected"); + } + }) + .onError((err: Error) => { + console.log("[GatewayConnection] error:", err.message); + if (disconnectingRef.current) return; + pairingKeyRef.current += 1; + clearIdentity(); + setIdentity(null); + setError(err.message); + setPageState("not-connected"); + clientRef.current?.disconnect(); + clientRef.current = null; + }) + .onSendError((err) => { + if (disconnectingRef.current) return; + setError(err.error); + }); + + clientRef.current = client; + client.connect(); + }; + + // If there's an existing client, disconnect first and wait for Gateway to process if (clientRef.current) { clientRef.current.disconnect(); clientRef.current = null; + setTimeout(doConnect, 300); + } else { + doConnect(); } - - disconnectingRef.current = false; - setPageState("connecting"); - setError(null); - - const deviceId = getDeviceId(); - - const client = new GatewayClient({ - url: id.gateway, - deviceId, - deviceType: "client", - hubId: id.hubId, - ...(token ? { token } : {}), - }) - .onStateChange((state: ConnectionState) => { - if (disconnectingRef.current) return; - setConnectionState(state); - if (state === "registered") { - saveIdentity(id); - setIdentity(id); - setPageState("connected"); - } - }) - .onError((err: Error) => { - if (disconnectingRef.current) return; - pairingKeyRef.current += 1; - clearIdentity(); - setIdentity(null); - setError(err.message); - setPageState("not-connected"); - clientRef.current?.disconnect(); - clientRef.current = null; - }) - .onSendError((err) => { - if (disconnectingRef.current) return; - setError(err.error); - }); - - clientRef.current = client; - client.connect(); }, [], ); @@ -123,15 +131,19 @@ export function useGatewayConnection(): UseGatewayConnectionReturn { // Try to reconnect with saved identity on mount useEffect(() => { const saved = loadIdentity(); + console.log("[GatewayConnection] mount, saved identity:", saved); if (!saved) { setPageState("not-connected"); return; } setIdentity(saved); - connectToGateway(saved); + // Delay reconnection — if a previous socket just disconnected (e.g. StrictMode + // cleanup or page navigation), the Gateway needs time to process it + const timer = setTimeout(() => connectToGateway(saved), 300); return () => { + clearTimeout(timer); clientRef.current?.disconnect(); clientRef.current = null; }; diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index 6cdd77d0..a5e17df9 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -34,7 +34,7 @@ export interface ChatViewProps { pendingApprovals: ChatViewApproval[]; sendMessage: (text: string) => void; resolveApproval: (approvalId: string, decision: "allow-once" | "allow-always" | "deny") => void; - onDisconnect: () => void; + onDisconnect?: () => void; } export function ChatView({ @@ -54,14 +54,16 @@ export function ChatView({ return (
-
- -
+ {onDisconnect && ( +
+ +
+ )}
{isLoadingHistory && messages.length === 0 ? ( @@ -144,14 +146,16 @@ export function ChatView({
{error.message} - + {onDisconnect && ( + + )}
)} diff --git a/packages/ui/src/components/chat.tsx b/packages/ui/src/components/chat.tsx deleted file mode 100644 index e862eac8..00000000 --- a/packages/ui/src/components/chat.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; - -import { useRef, useCallback, useState, useEffect } from "react"; -import { Button } from "@multica/ui/components/ui/button"; -import { ChatInput } from "@multica/ui/components/chat-input"; -import { useConnectionStore, useMessagesStore, useAutoConnect } from "@multica/store"; -import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; -import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll"; -import { useIsMobile } from "@multica/ui/hooks/use-mobile"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { CheckmarkCircle02Icon } from "@hugeicons/core-free-icons"; -import { ConnectPrompt } from "./connect-prompt"; -import { MessageList } from "./message-list"; -import { ChatSkeleton } from "./chat-skeleton"; - -export function Chat() { - const { loading } = useAutoConnect() - - const agentId = useConnectionStore((s) => s.agentId) - const gwState = useConnectionStore((s) => s.connectionState) - const hubId = useConnectionStore((s) => s.hubId) - const lastError = useConnectionStore((s) => s.lastError) - const isNewDevice = useConnectionStore((s) => s.isNewDevice) - const isMobile = useIsMobile() - - const messages = useMessagesStore((s) => s.messages) - const streamingIds = useMessagesStore((s) => s.streamingIds) - - // Show success overlay for 2s when a new device is approved by Owner - const [showVerifySuccess, setShowVerifySuccess] = useState(false) - useEffect(() => { - if (gwState === "registered" && isNewDevice === true) { - setShowVerifySuccess(true) - const timer = setTimeout(() => { - setShowVerifySuccess(false) - useConnectionStore.setState({ isNewDevice: null }) - }, 2000) - return () => clearTimeout(timer) - } - }, [gwState, isNewDevice]) - - const isConnected = gwState === "registered" && !!hubId && !!agentId - - const handleSend = useCallback((text: string) => { - const { hubId, agentId, send, connectionState } = useConnectionStore.getState() - if (connectionState !== "registered" || !hubId || !agentId) return - useMessagesStore.getState().sendMessage(text, { hubId, agentId, send }) - }, []) - - const handleDisconnect = useCallback(() => { - useConnectionStore.getState().disconnect() - }, []) - - const mainRef = useRef(null) - const fadeStyle = useScrollFade(mainRef) - useAutoScroll(mainRef) - - return ( -
- {/* Verify success overlay — shown for 2s when new device approved */} - {showVerifySuccess && ( -
- -
-

Connected

-

- Your device has been approved -

-
-
- )} - - {isConnected && ( -
- -
- )} - -
- {loading ? ( - - ) : !isConnected ? ( - - ) : messages.length === 0 ? ( -
- Your Agent is ready -
- ) : ( - - )} -
- - {/* Error banner */} - {lastError && ( -
-
- {lastError.message} ({lastError.code}) - -
-
- )} - - {/* Footer */} -
- -
-
- ); -} diff --git a/packages/ui/src/components/connect-prompt.tsx b/packages/ui/src/components/connect-prompt.tsx deleted file mode 100644 index 23a2e7b2..00000000 --- a/packages/ui/src/components/connect-prompt.tsx +++ /dev/null @@ -1,269 +0,0 @@ -"use client"; - -import { useState, useCallback, useRef, useEffect } from "react"; -import { Button } from "@multica/ui/components/ui/button"; -import { Textarea } from "@multica/ui/components/ui/textarea"; -import { - useConnectionStore, - parseConnectionCode, - saveConnection, -} from "@multica/store"; -import { useIsMobile } from "@multica/ui/hooks/use-mobile"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - Camera01Icon, - TextIcon, - CheckmarkCircle02Icon, - Alert02Icon, -} from "@hugeicons/core-free-icons"; -import { QrScannerView } from "@multica/ui/components/qr-scanner-view"; -import { Spinner } from "@multica/ui/components/spinner"; - -type Mode = "scan" | "paste"; -type PasteState = "idle" | "success" | "error"; - -/** Shown while connecting to Gateway or waiting for Owner approval */ -function ConnectionStatus({ fullscreen }: { fullscreen?: boolean }) { - const gwState = useConnectionStore((s) => s.connectionState); - const disconnect = useConnectionStore((s) => s.disconnect); - const isVerifying = gwState === "verifying"; - - const wrapper = fullscreen - ? "fixed inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6" - : "flex flex-col items-center justify-center h-full gap-5 px-4"; - - return ( -
- -
-

- {isVerifying ? "Waiting for approval" : "Connecting..."} -

-

- {isVerifying - ? "The device owner needs to approve this connection on their computer" - : "Establishing connection to the agent"} -

-
- -
- ); -} - -/** Shown when Owner rejects the connection, auto-dismisses after 2s */ -function RejectedStatus({ fullscreen, onDismiss }: { fullscreen?: boolean; onDismiss: () => void }) { - useEffect(() => { - const timer = setTimeout(onDismiss, 2000); - return () => clearTimeout(timer); - }, [onDismiss]); - - const wrapper = fullscreen - ? "fixed inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6" - : "flex flex-col items-center justify-center h-full gap-5 px-4"; - - return ( -
- -
-

Connection rejected

-

- The device owner declined this connection -

-
-
- ); -} - -export function ConnectPrompt() { - const gwState = useConnectionStore((s) => s.connectionState); - const lastError = useConnectionStore((s) => s.lastError); - const [mode, setMode] = useState("scan"); - const [codeInput, setCodeInput] = useState(""); - const [pasteState, setPasteState] = useState("idle"); - const [pasteError, setPasteError] = useState(null); - const [showRejected, setShowRejected] = useState(false); - const isMobile = useIsMobile(); - const validatingRef = useRef(false); - - // Detect verify rejection: lastError appears while disconnected - useEffect(() => { - if (lastError?.code === "VERIFY_ERROR" && gwState === "disconnected") { - setShowRejected(true); - } - }, [lastError, gwState]); - - const handleDismissRejected = useCallback(() => { - setShowRejected(false); - useConnectionStore.setState({ lastError: null }); - }, []); - - const tryConnect = useCallback((raw: string) => { - const trimmed = raw.trim(); - if (!trimmed || validatingRef.current) return; - validatingRef.current = true; - try { - const info = parseConnectionCode(trimmed); - setPasteState("success"); - navigator.vibrate?.(50); - // Let the user see the success state before connecting - setTimeout(() => { - saveConnection(info); - useConnectionStore.getState().connect(info); - }, 600); - } catch (e) { - setPasteState("error"); - setPasteError((e as Error).message || "Invalid code"); - navigator.vibrate?.([30, 50, 30]); - setTimeout(() => { - setPasteState("idle"); - setPasteError(null); - setCodeInput(""); - }, 2000); - } finally { - validatingRef.current = false; - } - }, []); - - // Auto-validate on paste - const handlePaste = useCallback( - (e: React.ClipboardEvent) => { - const text = e.clipboardData.getData("text"); - if (!text.trim()) return; - // Let the textarea update visually first, then validate - setTimeout(() => tryConnect(text), 50); - }, - [tryConnect], - ); - - // Promise-based handler for QrScannerView - const handleScanResult = useCallback(async (data: string) => { - const info = parseConnectionCode(data); - saveConnection(info); - useConnectionStore.getState().connect(info); - }, []); - - const isInProgress = - gwState === "connecting" || - gwState === "connected" || - gwState === "verifying"; - - // Verification rejected — show rejection feedback - if (showRejected) { - return ; - } - - // Connection in progress — show status (replaces scanner/paste) - if (isInProgress) { - return ; - } - - // Mobile: scanner only, no tabs, no paste - if (isMobile) { - return ( -
-
-

Scan to start

-

- Scan a Multica QR code to start chatting -

-
- -
- ); - } - - // Desktop: tab toggle (scan / paste), same-size panels - return ( -
-
-

- {mode === "scan" ? "Scan to start" : "Paste to start"} -

-

- {mode === "scan" - ? "Scan a Multica QR code to start chatting" - : "Paste a Multica connection code to start chatting"} -

-
- - {/* Mode toggle */} -
- - -
- - {/* Content — same max-width for both modes */} -
- {mode === "scan" ? ( - - ) : ( -
- {pasteState === "idle" && ( -