diff --git a/apps/web/package.json b/apps/web/package.json
index 95b1b67c..0badea42 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev --port 3001",
+ "dev": "next dev --port 3001 --experimental-https",
"build": "next build",
"start": "next start",
"lint": "eslint"
diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts
index fb362281..cf7bf932 100644
--- a/packages/sdk/src/client.ts
+++ b/packages/sdk/src/client.ts
@@ -234,7 +234,7 @@ export class GatewayClient {
}
/** Hub 验证成功回调 */
- onVerified(callback: (result: { hubId: string; agentId: string }) => void): this {
+ onVerified(callback: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void): this {
this.callbacks.onVerified = callback;
return this;
}
@@ -312,12 +312,13 @@ export class GatewayClient {
if (this.options.hubId) {
// Set internal state to allow send/request during verify
this._state = "registered";
+ this.callbacks.onStateChange?.("verifying");
const meta = typeof navigator !== "undefined" ? {
userAgent: navigator.userAgent,
platform: navigator.platform,
language: navigator.language,
} : undefined;
- this.request<{ hubId: string; agentId: string }>(
+ this.request<{ hubId: string; agentId: string; isNewDevice?: boolean }>(
this.options.hubId,
"verify",
{ token: this.options.token, meta },
diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts
index 88118021..f8a3f693 100644
--- a/packages/sdk/src/types.ts
+++ b/packages/sdk/src/types.ts
@@ -102,6 +102,7 @@ export type ConnectionState =
| "disconnected"
| "connecting"
| "connected"
+ | "verifying"
| "registered";
/** Event callback types */
@@ -109,7 +110,7 @@ export interface GatewayClientCallbacks {
onConnect?: (socketId: string) => void;
onDisconnect?: (reason: string) => void;
onRegistered?: (deviceId: string) => void;
- onVerified?: (result: { hubId: string; agentId: string }) => void;
+ onVerified?: (result: { hubId: string; agentId: string; isNewDevice?: boolean }) => void;
onMessage?: (message: RoutedMessage) => void;
onSendError?: (error: SendErrorResponse) => void;
onPong?: (data: string) => void;
diff --git a/packages/store/src/connection-store.ts b/packages/store/src/connection-store.ts
index fd5c09e0..17b41a51 100644
--- a/packages/store/src/connection-store.ts
+++ b/packages/store/src/connection-store.ts
@@ -35,6 +35,8 @@ interface ConnectionStoreState {
agentId: string | null
connectionState: ConnectionState
lastError: { code: string; message: string } | null
+ /** Whether the current connection required Owner approval (new device) */
+ isNewDevice: boolean | null
}
interface ConnectionStoreActions {
@@ -158,6 +160,8 @@ function createClient(
useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId)
}
})
+ .onVerified((result) => set({ isNewDevice: result.isNewDevice ?? false }))
+ .onError((error) => set({ lastError: { code: "VERIFY_ERROR", message: error.message } }))
.onSendError((error) => set({ lastError: { code: error.code, message: error.error } }))
}
@@ -252,6 +256,7 @@ export const useConnectionStore = create()(
agentId: null,
connectionState: "disconnected",
lastError: null,
+ isNewDevice: null,
// Connect using a connection code (disconnect existing connection first)
connect: (code) => {
@@ -284,6 +289,7 @@ export const useConnectionStore = create()(
hubId: null,
agentId: null,
lastError: null,
+ isNewDevice: null,
})
},
diff --git a/packages/ui/src/components/chat.tsx b/packages/ui/src/components/chat.tsx
index ea51d036..e862eac8 100644
--- a/packages/ui/src/components/chat.tsx
+++ b/packages/ui/src/components/chat.tsx
@@ -1,11 +1,14 @@
"use client";
-import { useRef, useCallback } from "react";
+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";
@@ -17,10 +20,25 @@ export function Chat() {
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) => {
@@ -39,6 +57,26 @@ export function Chat() {
return (
+ {/* Verify success overlay — shown for 2s when new device approved */}
+ {showVerifySuccess && (
+
+
+
+
Connected
+
+ Your device has been approved
+
+
+
+ )}
+
{isConnected && (
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"}
+
+
+
+ Cancel
+
+
+ );
+}
+
+/** 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;
@@ -75,7 +152,20 @@ export function ConnectPrompt() {
useConnectionStore.getState().connect(info);
}, []);
- const isConnecting = gwState === "connecting" || gwState === "connected";
+ 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) {
@@ -86,11 +176,6 @@ export function ConnectPrompt() {
Scan a Multica QR code to start chatting
- {isConnecting && (
-
- Connecting to Agent...
-
- )}
@@ -109,11 +194,6 @@ export function ConnectPrompt() {
? "Scan a Multica QR code to start chatting"
: "Paste a Multica connection code to start chatting"}
- {isConnecting && (
-
- Connecting to Agent...
-
- )}
{/* Mode toggle */}
diff --git a/packages/ui/src/components/qr-scanner-view.tsx b/packages/ui/src/components/qr-scanner-view.tsx
index 568d26fa..41dcaef8 100644
--- a/packages/ui/src/components/qr-scanner-view.tsx
+++ b/packages/ui/src/components/qr-scanner-view.tsx
@@ -31,6 +31,7 @@ export interface QrScannerProps {
}
const ACTIVE_STATES: ScannerState[] = [
+ "requesting",
"scanning",
"detected",
"success",
diff --git a/src/hub/rpc/handlers/verify.ts b/src/hub/rpc/handlers/verify.ts
index 53b98735..67bcfda8 100644
--- a/src/hub/rpc/handlers/verify.ts
+++ b/src/hub/rpc/handlers/verify.ts
@@ -21,7 +21,7 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
// 1. Already in whitelist → pass through (reconnection, no confirmation needed)
const allowed = ctx.deviceStore.isAllowed(from);
if (allowed) {
- return { hubId: ctx.hubId, agentId: allowed.agentId };
+ return { hubId: ctx.hubId, agentId: allowed.agentId, isNewDevice: false };
}
// 2. Validate token
@@ -42,6 +42,6 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
// 4. User confirmed → add to whitelist (with device metadata)
ctx.deviceStore.allowDevice(from, result.agentId, meta);
- return { hubId: ctx.hubId, agentId: result.agentId };
+ return { hubId: ctx.hubId, agentId: result.agentId, isNewDevice: true };
};
}