feat(connection): add device verification status feedback for collaborators

Add a "verifying" connection state between "connected" and "registered"
so collaborators see clear feedback while waiting for the device owner
to approve their connection on Desktop.

Changes across the stack:
- Hub: verify RPC returns isNewDevice flag to distinguish new vs whitelisted
- SDK: emit "verifying" state before verify RPC, pass isNewDevice through
- Store: capture isNewDevice via onVerified, capture rejection via onError
- UI: ConnectionStatus (waiting), RejectedStatus (declined), and
  verify success overlay (approved) replace the stuck scanner screen

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 10:44:15 +08:00
parent 963bb6c0f9
commit 037908cf8d
6 changed files with 144 additions and 18 deletions

View file

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

View file

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

View file

@ -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<ConnectionStore>()(
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<ConnectionStore>()(
hubId: null,
agentId: null,
lastError: null,
isNewDevice: null,
})
},

View file

@ -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 (
<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

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useRef, useEffect } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Textarea } from "@multica/ui/components/ui/textarea";
import {
@ -17,19 +17,96 @@ import {
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;
@ -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 <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) {
@ -86,11 +176,6 @@ export function ConnectPrompt() {
<p className="text-xs text-muted-foreground">
Scan a Multica QR code to start chatting
</p>
{isConnecting && (
<p className="text-sm text-foreground/70 animate-pulse">
Connecting to Agent...
</p>
)}
</div>
<QrScannerView onResult={handleScanResult} fullscreen />
</div>
@ -109,11 +194,6 @@ export function ConnectPrompt() {
? "Scan a Multica QR code to start chatting"
: "Paste a Multica connection code to start chatting"}
</p>
{isConnecting && (
<p className="text-sm text-foreground/70 animate-pulse">
Connecting to Agent...
</p>
)}
</div>
{/* Mode toggle */}

View file

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