Merge pull request #91 from multica-ai/feat/mobile-pwa-optimization-2
feat(connection): add device verification status feedback
This commit is contained in:
commit
f1dff1e93b
8 changed files with 146 additions and 19 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export interface QrScannerProps {
|
|||
}
|
||||
|
||||
const ACTIVE_STATES: ScannerState[] = [
|
||||
"requesting",
|
||||
"scanning",
|
||||
"detected",
|
||||
"success",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue