Merge pull request #91 from multica-ai/feat/mobile-pwa-optimization-2

feat(connection): add device verification status feedback
This commit is contained in:
Naiyuan Qing 2026-02-05 10:48:33 +08:00 committed by GitHub
commit f1dff1e93b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 146 additions and 19 deletions

View file

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

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

@ -31,6 +31,7 @@ export interface QrScannerProps {
}
const ACTIVE_STATES: ScannerState[] = [
"requesting",
"scanning",
"detected",
"success",

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