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 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 17:50:47 +08:00
parent 874de766ec
commit 7a21686505
9 changed files with 320 additions and 715 deletions

View file

@ -13,3 +13,5 @@ export type {
PendingApproval,
UseChatReturn,
} from "./use-chat";
export { useGatewayChat } from "./use-gateway-chat";

View file

@ -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<string>;
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<Message[]>([]);
const [streamingIds, setStreamingIds] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
const [error, setError] = useState<ChatError | null>(null);
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([]);
// Keep a ref for use inside callbacks (avoids stale closures)
const messagesRef = useRef(messages);
messagesRef.current = messages;
const [error, setError] = useState<ChatError | null>(null);
// Fetch history on mount
useEffect(() => {
async function fetchHistory() {
try {
const result = await client.request<GetAgentMessagesResult>(
hubId,
"getAgentMessages",
{ agentId, limit: 200 },
);
const isStreaming = streamingIds.size > 0;
// Build toolCallId → args lookup from assistant tool_use blocks
const toolCallArgsMap = new Map<string, { name: string; args: Record<string, unknown> }>();
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<string, { name: string; args: Record<string, unknown> }>();
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<string, unknown> | 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<string, unknown> | 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<typeof useChat>;

View file

@ -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<GetAgentMessagesResult>(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,
};
}

View file

@ -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;
};

View file

@ -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 (
<div className="flex-1 flex flex-col min-h-0">
<div className="container flex items-center justify-end px-4 py-2">
<button
onClick={onDisconnect}
className="text-xs text-muted-foreground hover:text-foreground"
>
Disconnect
</button>
</div>
{onDisconnect && (
<div className="container flex items-center justify-end px-4 py-2">
<button
onClick={onDisconnect}
className="text-xs text-muted-foreground hover:text-foreground"
>
Disconnect
</button>
</div>
)}
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
{isLoadingHistory && messages.length === 0 ? (
@ -144,14 +146,16 @@ export function ChatView({
<div className="container px-4" role="alert" aria-live="polite">
<div className="rounded-lg bg-destructive/5 border border-destructive/15 text-xs px-3 py-2 flex items-center justify-between gap-3">
<span className="text-foreground leading-snug">{error.message}</span>
<Button
variant="destructive"
size="sm"
onClick={onDisconnect}
className="shrink-0 text-xs h-7 px-2.5"
>
Disconnect
</Button>
{onDisconnect && (
<Button
variant="destructive"
size="sm"
onClick={onDisconnect}
className="shrink-0 text-xs h-7 px-2.5"
>
Disconnect
</Button>
)}
</div>
</div>
)}

View file

@ -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<HTMLElement>(null)
const fadeStyle = useScrollFade(mainRef)
useAutoScroll(mainRef)
return (
<div className="h-full flex flex-col overflow-hidden w-full">
{/* Verify success overlay — shown for 2s when new device approved */}
{showVerifySuccess && (
<div className={
isMobile
? "fixed inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6 animate-in fade-in duration-300"
: "absolute inset-0 z-50 bg-background flex flex-col items-center justify-center gap-5 px-6 animate-in fade-in duration-300"
}>
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
className="size-14 text-(--tool-success) animate-in zoom-in duration-300"
/>
<div className="text-center space-y-1.5">
<p className="text-base font-medium">Connected</p>
<p className="text-xs text-muted-foreground">
Your device has been approved
</p>
</div>
</div>
)}
{isConnected && (
<div className="flex items-center justify-end px-4 py-1 max-w-4xl mx-auto w-full">
<Button
variant="ghost"
size="sm"
onClick={handleDisconnect}
className="text-xs text-muted-foreground"
>
Disconnect
</Button>
</div>
)}
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
{loading ? (
<ChatSkeleton />
) : !isConnected ? (
<ConnectPrompt />
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Your Agent is ready
</div>
) : (
<MessageList messages={messages} streamingIds={streamingIds} />
)}
</main>
{/* Error banner */}
{lastError && (
<div className="px-4 py-2 max-w-4xl mx-auto w-full" role="alert" aria-live="polite">
<div className="rounded-md bg-destructive/10 text-destructive text-sm px-3 py-2 flex items-center justify-between">
<span>{lastError.message} ({lastError.code})</span>
<button
type="button"
aria-label="Dismiss error"
onClick={() => useConnectionStore.setState({ lastError: null })}
className="text-destructive/60 hover:text-destructive ml-2 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded outline-none"
>
&times;
</button>
</div>
</div>
)}
{/* Footer */}
<footer className="w-full p-2 pt-1 max-w-4xl mx-auto">
<ChatInput
onSubmit={handleSend}
disabled={!isConnected}
placeholder={!isConnected ? "Scan QR code to get started" : "Ask your Agent..."}
/>
</footer>
</div>
);
}

View file

@ -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 (
<div className={wrapper}>
<Spinner className="text-muted-foreground text-sm" />
<div className="text-center space-y-1.5">
<p className="text-base font-medium">
{isVerifying ? "Waiting for approval" : "Connecting..."}
</p>
<p className="text-xs text-muted-foreground max-w-[260px]">
{isVerifying
? "The device owner needs to approve this connection on their computer"
: "Establishing connection to the agent"}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
onClick={disconnect}
>
Cancel
</Button>
</div>
);
}
/** 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 (
<div className={wrapper}>
<HugeiconsIcon
icon={Alert02Icon}
className="size-14 text-destructive animate-in zoom-in duration-300"
/>
<div className="text-center space-y-1.5">
<p className="text-base font-medium">Connection rejected</p>
<p className="text-xs text-muted-foreground max-w-[260px]">
The device owner declined this connection
</p>
</div>
</div>
);
}
export function ConnectPrompt() {
const gwState = useConnectionStore((s) => s.connectionState);
const lastError = useConnectionStore((s) => s.lastError);
const [mode, setMode] = useState<Mode>("scan");
const [codeInput, setCodeInput] = useState("");
const [pasteState, setPasteState] = useState<PasteState>("idle");
const [pasteError, setPasteError] = useState<string | null>(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 <RejectedStatus fullscreen={isMobile} onDismiss={handleDismissRejected} />;
}
// Connection in progress — show status (replaces scanner/paste)
if (isInProgress) {
return <ConnectionStatus fullscreen={isMobile} />;
}
// Mobile: scanner only, no tabs, no paste
if (isMobile) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
<div className="text-center space-y-1">
<p className="text-base font-medium">Scan to start</p>
<p className="text-xs text-muted-foreground">
Scan a Multica QR code to start chatting
</p>
</div>
<QrScannerView onResult={handleScanResult} fullscreen />
</div>
);
}
// Desktop: tab toggle (scan / paste), same-size panels
return (
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
<div className="text-center space-y-1">
<p className="text-base font-medium">
{mode === "scan" ? "Scan to start" : "Paste to start"}
</p>
<p className="text-xs text-muted-foreground">
{mode === "scan"
? "Scan a Multica QR code to start chatting"
: "Paste a Multica connection code to start chatting"}
</p>
</div>
{/* Mode toggle */}
<div className="flex gap-1 bg-muted rounded-lg p-1">
<Button
variant={mode === "scan" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => setMode("scan")}
>
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" />
Scan
</Button>
<Button
variant={mode === "paste" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => setMode("paste")}
>
<HugeiconsIcon icon={TextIcon} className="size-3.5" />
Paste
</Button>
</div>
{/* Content — same max-width for both modes */}
<div className="w-full max-w-[320px]">
{mode === "scan" ? (
<QrScannerView onResult={handleScanResult} />
) : (
<div className="aspect-square rounded-xl bg-muted flex flex-col items-center justify-center p-4">
{pasteState === "idle" && (
<Textarea
value={codeInput}
onChange={(e) => setCodeInput(e.target.value)}
onPaste={handlePaste}
autoFocus={true}
placeholder="Paste connection code here..."
className="text-xs font-mono flex-1 resize-none bg-transparent! border-0 focus-visible:ring-0 shadow-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
tryConnect(codeInput);
}
}}
/>
)}
{pasteState === "success" && (
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
className="size-14 text-(--tool-success) animate-in zoom-in duration-300"
/>
)}
{pasteState === "error" && (
<div className="flex flex-col items-center justify-center gap-2">
<HugeiconsIcon
icon={Alert02Icon}
className="size-12 text-(--tool-error)"
/>
{pasteError && (
<p className="text-xs text-destructive bg-destructive/10 px-3 py-1.5 rounded-full">
{pasteError}
</p>
)}
</div>
)}
</div>
)}
</div>
</div>
);
}

View file

@ -221,7 +221,7 @@ export function DevicePairing({
// Mobile: scanner only
if (isMobile) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
<div className="flex flex-col items-center justify-center h-full gap-4 px-4 mb-28">
<PairingHeader
title="Scan to connect"
description="Scan a Multica QR code to connect to your agent"
@ -233,7 +233,7 @@ export function DevicePairing({
// Desktop: tab toggle (scan / paste)
return (
<div className="flex flex-col items-center justify-center h-full gap-4 px-4 mb-14 sm:mb-28">
<div className="flex flex-col items-center justify-center h-full gap-4 px-4 mb-28">
<PairingHeader
title={mode === "scan" ? "Scan to connect" : "Paste to connect"}
description={mode === "scan"

View file

@ -55,7 +55,7 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }:
}, [messages])
return (
<div className="relative px-4 py-6 max-w-4xl mx-auto">
<div className="relative p-6 max-w-4xl mx-auto">
{messages.map((msg) => {
// ToolResult messages → render as tool execution item
if (msg.role === "toolResult") {