feat(ui): add paste mode success/error feedback with state-based rendering

Replace overlay approach with conditional rendering for paste validation
states. Success shows checkmark icon with 600ms delay before connecting,
error shows alert with message and auto-resets after 2s. Improves
Connecting text visibility.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 09:53:09 +08:00
parent 544425ae40
commit cff9c004db
2 changed files with 67 additions and 38 deletions

View file

@ -3,7 +3,6 @@
import { useState, useCallback, useRef } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { toast } from "@multica/ui/components/ui/sonner";
import {
useConnectionStore,
parseConnectionCode,
@ -11,15 +10,23 @@ import {
} from "@multica/store";
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { HugeiconsIcon } from "@hugeicons/react";
import { Camera01Icon, TextIcon } from "@hugeicons/core-free-icons";
import {
Camera01Icon,
TextIcon,
CheckmarkCircle02Icon,
Alert02Icon,
} from "@hugeicons/core-free-icons";
import { QrScannerView } from "@multica/ui/components/qr-scanner-view";
type Mode = "scan" | "paste";
type PasteState = "idle" | "success" | "error";
export function ConnectPrompt() {
const gwState = useConnectionStore((s) => s.connectionState);
const [mode, setMode] = useState<Mode>("scan");
const [codeInput, setCodeInput] = useState("");
const [pasteState, setPasteState] = useState<PasteState>("idle");
const [pasteError, setPasteError] = useState<string | null>(null);
const isMobile = useIsMobile();
const validatingRef = useRef(false);
@ -29,10 +36,22 @@ export function ConnectPrompt() {
validatingRef.current = true;
try {
const info = parseConnectionCode(trimmed);
saveConnection(info);
useConnectionStore.getState().connect(info);
setPasteState("success");
navigator.vibrate?.(50);
// Let the user see the success state before connecting
setTimeout(() => {
saveConnection(info);
useConnectionStore.getState().connect(info);
}, 600);
} catch (e) {
toast.error((e as Error).message);
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;
}
@ -65,10 +84,10 @@ export function ConnectPrompt() {
<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 QR code to use an Agent
Scan a Multica QR code to start chatting
</p>
{isConnecting && (
<p className="text-xs text-muted-foreground/60 animate-pulse">
<p className="text-sm text-foreground/70 animate-pulse">
Connecting to Agent...
</p>
)}
@ -87,11 +106,11 @@ export function ConnectPrompt() {
</p>
<p className="text-xs text-muted-foreground">
{mode === "scan"
? "Scan a QR code to use an Agent"
: "Paste a connection code to use an Agent"}
? "Scan a Multica QR code to start chatting"
: "Paste a Multica connection code to start chatting"}
</p>
{isConnecting && (
<p className="text-xs text-muted-foreground/60 animate-pulse">
<p className="text-sm text-foreground/70 animate-pulse">
Connecting to Agent...
</p>
)}
@ -124,20 +143,44 @@ export function ConnectPrompt() {
{mode === "scan" ? (
<QrScannerView onResult={handleScanResult} />
) : (
<div className="aspect-square rounded-xl bg-muted flex flex-col items-center justify-center p-6">
<Textarea
value={codeInput}
onChange={(e) => setCodeInput(e.target.value)}
onPaste={handlePaste}
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);
}
}}
/>
<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>

View file

@ -243,20 +243,6 @@ export function QrScannerView({
</div>
)}
{/* Center crosshair */}
{state === "scanning" && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="text-white/30"
>
<line x1="12" y1="4" x2="12" y2="20" stroke="currentColor" strokeWidth="1" />
<line x1="4" y1="12" x2="20" y2="12" stroke="currentColor" strokeWidth="1" />
</svg>
</div>
)}
{/* Close button */}
{(state === "scanning" || state === "detected") && (