refactor(ui,hooks): extract shared ChatView and DevicePairing to packages

- Extract ChatView from web chat-page into packages/ui as a prop-driven
  component (accepts UseChatReturn shape, no transport dependency)
- Move DevicePairing from apps/web to packages/ui with locally defined
  ConnectionIdentity type (no @multica/hooks dependency)
- Create @multica/hooks package with useGatewayConnection and useChat
  (moved from apps/web/hooks)
- Add isLoadingHistory state to useChat with skeleton loading in ChatView
- Add MulticaIcon (pure CSS asterisk via clip-path, adapts to theme)
- Slim web chat-page.tsx from 188 to 65 lines (just wires hooks to UI)

Desktop can now reuse ChatView and DevicePairing directly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 15:28:44 +08:00
parent 7fdbf24c4e
commit 53c350ea33
12 changed files with 316 additions and 89 deletions

View file

@ -117,7 +117,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
<EditorContent editor={editor} />
<div className="flex items-center justify-end pt-2">
<Button size="icon-lg" onClick={handleSubmit} disabled={disabled}>
<HugeiconsIcon strokeWidth={2.5} icon={ArrowUpIcon} />
<HugeiconsIcon className="size-4.5" strokeWidth={2.5} icon={ArrowUpIcon} />
</Button>
</div>
</div>

View file

@ -0,0 +1,168 @@
"use client";
import { useRef } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { ChatInput } from "@multica/ui/components/chat-input";
import { MessageList } from "@multica/ui/components/message-list";
import { MulticaIcon } from "@multica/ui/components/multica-icon";
import { ExecApprovalItem } from "@multica/ui/components/exec-approval-item";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
import type { Message } from "@multica/store";
export interface ChatViewError {
code: string;
message: string;
}
export interface ChatViewApproval {
approvalId: string;
command: string;
cwd?: string;
riskLevel: "safe" | "needs-review" | "dangerous";
riskReasons: string[];
expiresAtMs: number;
}
export interface ChatViewProps {
messages: Message[];
streamingIds: Set<string>;
isLoading: boolean;
isLoadingHistory: boolean;
error: ChatViewError | null;
pendingApprovals: ChatViewApproval[];
sendMessage: (text: string) => void;
resolveApproval: (approvalId: string, decision: "allow-once" | "allow-always" | "deny") => void;
onDisconnect: () => void;
}
export function ChatView({
messages,
streamingIds,
isLoading,
isLoadingHistory,
error,
pendingApprovals,
sendMessage,
resolveApproval,
onDisconnect,
}: ChatViewProps) {
const mainRef = useRef<HTMLElement>(null);
const fadeStyle = useScrollFade(mainRef);
useAutoScroll(mainRef);
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="container flex items-center justify-end px-4 py-2">
<button
onClick={onDisconnect}
className="text-xs text-muted-foreground hover:text-foreground"
>
Disconnect
</button>
</div>
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
{isLoadingHistory && messages.length === 0 ? (
<div className="px-4 py-6 max-w-4xl mx-auto">
{/* User bubble */}
<div className="flex justify-end my-2">
<Skeleton className="h-8 w-[30%] rounded-md" />
</div>
{/* Assistant multi-line */}
<div className="space-y-2 py-1 px-2.5 my-1">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-[88%]" />
<Skeleton className="h-3.5 w-[65%]" />
</div>
{/* Tool row */}
<div className="px-2.5 my-1">
<Skeleton className="h-6 w-44 rounded" />
</div>
{/* Assistant short reply */}
<div className="space-y-2 py-1 px-2.5 my-1">
<Skeleton className="h-3.5 w-[92%]" />
<Skeleton className="h-3.5 w-[55%]" />
</div>
{/* User bubble */}
<div className="flex justify-end my-2">
<Skeleton className="h-8 w-[42%] rounded-md" />
</div>
{/* Assistant reply */}
<div className="space-y-2 py-1 px-2.5 my-1">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-[80%]" />
<Skeleton className="h-3.5 w-[70%]" />
<Skeleton className="h-3.5 w-[40%]" />
</div>
{/* User bubble */}
<div className="flex justify-end my-2">
<Skeleton className="h-8 w-[22%] rounded-md" />
</div>
{/* Assistant reply */}
<div className="space-y-2 py-1 px-2.5 my-1">
<Skeleton className="h-3.5 w-[75%]" />
<Skeleton className="h-3.5 w-[50%]" />
</div>
</div>
) : messages.length === 0 && pendingApprovals.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-3 text-muted-foreground">
<MulticaIcon className="size-5 shrink-0" />
<div>
<p className="text-sm font-medium">Start a conversation</p>
<p className="text-xs text-muted-foreground/70">
Type a message below to chat with your Agent
</p>
</div>
</div>
</div>
) : (
<>
<MessageList messages={messages} streamingIds={streamingIds} />
{pendingApprovals.length > 0 && (
<div className="relative px-4 max-w-4xl mx-auto">
{pendingApprovals.map((approval) => (
<ExecApprovalItem
key={approval.approvalId}
command={approval.command}
cwd={approval.cwd}
riskLevel={approval.riskLevel}
riskReasons={approval.riskReasons}
expiresAtMs={approval.expiresAtMs}
onDecision={(decision) => resolveApproval(approval.approvalId, decision)}
/>
))}
</div>
)}
</>
)}
</main>
{error && (
<div className="container px-4" role="alert" aria-live="polite">
<div className="rounded-lg bg-destructive/5 border border-destructive/15 text-xs px-3 py-2 flex items-center justify-between gap-3">
<span className="text-foreground leading-snug">{error.message}</span>
<Button
variant="destructive"
size="sm"
onClick={onDisconnect}
className="shrink-0 text-xs h-7 px-2.5"
>
Disconnect
</Button>
</div>
</div>
)}
<footer className="container px-4 pb-2 pt-1">
<ChatInput
onSubmit={sendMessage}
disabled={isLoading || !!error}
placeholder={error ? "Connection error" : "Ask your Agent..."}
/>
</footer>
</div>
);
}

View file

@ -0,0 +1,311 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { Loading } from "@multica/ui/components/ui/loading";
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Camera01Icon,
TextIcon,
CheckmarkCircle02Icon,
Alert02Icon,
} from "@hugeicons/core-free-icons";
import { QrScannerView } from "@multica/ui/components/qr-scanner-view";
import { MulticaIcon } from "@multica/ui/components/multica-icon";
import { parseConnectionCode } from "@multica/store";
export interface ConnectionIdentity {
gateway: string;
hubId: string;
agentId: string;
}
export interface DevicePairingProps {
connectionState: string;
lastError: string | null;
onConnect: (identity: ConnectionIdentity, token: string) => void;
onCancel: () => void;
}
type Mode = "scan" | "paste";
type PasteState = "idle" | "success" | "error";
/** Shown while connecting to Gateway or waiting for Owner approval */
function ConnectionStatus({
connectionState,
fullscreen,
onCancel,
}: {
connectionState: string;
fullscreen?: boolean;
onCancel: () => void;
}) {
const isVerifying = connectionState === "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}>
<Loading 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={onCancel}
>
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 DevicePairing({
connectionState,
lastError,
onConnect,
onCancel,
}: DevicePairingProps) {
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
useEffect(() => {
if (lastError && connectionState === "disconnected") {
setShowRejected(true);
}
}, [lastError, connectionState]);
const handleDismissRejected = useCallback(() => {
setShowRejected(false);
}, []);
const tryConnect = useCallback(
(raw: string) => {
const trimmed = raw.trim();
if (!trimmed || validatingRef.current) return;
validatingRef.current = true;
try {
const info = parseConnectionCode(trimmed);
setPasteState("success");
navigator.vibrate?.(50);
setTimeout(() => {
onConnect(
{ gateway: info.gateway, hubId: info.hubId, agentId: info.agentId },
info.token,
);
}, 600);
} catch (e) {
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;
}
},
[onConnect],
);
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
const text = e.clipboardData.getData("text");
if (!text.trim()) return;
setTimeout(() => tryConnect(text), 50);
},
[tryConnect],
);
const handleScanResult = useCallback(
async (data: string) => {
const info = parseConnectionCode(data);
onConnect(
{ gateway: info.gateway, hubId: info.hubId, agentId: info.agentId },
info.token,
);
},
[onConnect],
);
const isInProgress =
connectionState === "connecting" ||
connectionState === "connected" ||
connectionState === "verifying";
if (showRejected) {
return (
<RejectedStatus fullscreen={isMobile} onDismiss={handleDismissRejected} />
);
}
if (isInProgress) {
return (
<ConnectionStatus
connectionState={connectionState}
fullscreen={isMobile}
onCancel={onCancel}
/>
);
}
// Mobile: scanner only
if (isMobile) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-2">
<MulticaIcon className="size-4.5 text-muted-foreground/50" />
<p className="text-base font-medium">Scan to connect</p>
</div>
<p className="text-xs text-muted-foreground">
Scan a Multica QR code to connect to your agent
</p>
</div>
<QrScannerView onResult={handleScanResult} fullscreen />
</div>
);
}
// Desktop: tab toggle (scan / paste)
return (
<div className="flex flex-col items-center justify-center h-full gap-4 px-4 mb-28">
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-2">
<MulticaIcon className="size-4.5 text-muted-foreground/50" />
<p className="text-base font-medium">
{mode === "scan" ? "Scan to connect" : "Paste to connect"}
</p>
</div>
<p className="text-xs text-muted-foreground">
{mode === "scan"
? "Scan a Multica QR code to connect to your agent"
: "Paste a Multica connection code to connect to your agent"}
</p>
</div>
{/* Mode toggle */}
<div className="flex gap-1 bg-muted rounded-lg p-1">
<Button
variant={mode === "scan" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => setMode("scan")}
>
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" />
Scan
</Button>
<Button
variant={mode === "paste" ? "default" : "ghost"}
size="sm"
className="text-xs gap-1.5 h-7 px-3"
onClick={() => setMode("paste")}
>
<HugeiconsIcon icon={TextIcon} className="size-3.5" />
Paste
</Button>
</div>
{/* Content */}
<div className="w-full max-w-[320px]">
{mode === "scan" ? (
<QrScannerView onResult={handleScanResult} />
) : (
<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>
</div>
);
}

View file

@ -0,0 +1,33 @@
import { cn } from "@multica/ui/lib/utils";
/**
* Pure CSS 8-pointed asterisk icon matching the Multica logo.
* Uses currentColor so it adapts to light/dark themes automatically.
* Clip-path polygon traced from the original SVG path coordinates.
*/
export function MulticaIcon({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
className={cn("inline-block size-[1em]", className)}
aria-hidden="true"
{...props}
>
<span
className="block size-full bg-current"
style={{
clipPath: `polygon(
45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%,
81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%,
100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%,
55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%,
18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%,
0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9%
)`,
}}
/>
</span>
);
}