From 53c350ea335150177906e71ed18939b85548027c Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:28:44 +0800 Subject: [PATCH] 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 --- apps/web/components/pages/chat-page.tsx | 89 +--------- apps/web/package.json | 1 + packages/hooks/package.json | 20 +++ packages/hooks/src/index.ts | 15 ++ .../hooks => packages/hooks/src}/use-chat.ts | 6 +- .../hooks/src}/use-gateway-connection.ts | 0 packages/hooks/tsconfig.json | 20 +++ packages/ui/src/components/chat-input.tsx | 2 +- packages/ui/src/components/chat-view.tsx | 168 ++++++++++++++++++ .../ui/src}/components/device-pairing.tsx | 22 ++- packages/ui/src/components/multica-icon.tsx | 33 ++++ pnpm-lock.yaml | 29 ++- 12 files changed, 316 insertions(+), 89 deletions(-) create mode 100644 packages/hooks/package.json create mode 100644 packages/hooks/src/index.ts rename {apps/web/hooks => packages/hooks/src}/use-chat.ts (97%) rename {apps/web/hooks => packages/hooks/src}/use-gateway-connection.ts (100%) create mode 100644 packages/hooks/tsconfig.json create mode 100644 packages/ui/src/components/chat-view.tsx rename {apps/web => packages/ui/src}/components/device-pairing.tsx (92%) create mode 100644 packages/ui/src/components/multica-icon.tsx diff --git a/apps/web/components/pages/chat-page.tsx b/apps/web/components/pages/chat-page.tsx index 23166da1..585286a8 100644 --- a/apps/web/components/pages/chat-page.tsx +++ b/apps/web/components/pages/chat-page.tsx @@ -1,17 +1,11 @@ "use client"; -import { useRef } from "react"; import { Header } from "@/app/header"; -import { Button } from "@multica/ui/components/ui/button"; import { Loading } from "@multica/ui/components/ui/loading"; -import { ChatInput } from "@multica/ui/components/chat-input"; -import { MessageList } from "@multica/ui/components/message-list"; -import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"; -import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll"; -import { useGatewayConnection } from "@/hooks/use-gateway-connection"; -import { useChat } from "@/hooks/use-chat"; -import { ExecApprovalItem } from "@multica/ui/components/exec-approval-item"; -import { DevicePairing } from "@/components/device-pairing"; +import { ChatView } from "@multica/ui/components/chat-view"; +import { DevicePairing } from "@multica/ui/components/device-pairing"; +import { useGatewayConnection } from "@multica/hooks/use-gateway-connection"; +import { useChat } from "@multica/hooks/use-chat"; const ChatPage = () => { const { pageState, connectionState, identity, error, client, pairingKey, connect, disconnect } = @@ -39,7 +33,7 @@ const ChatPage = () => { )} {pageState === "connected" && client && identity && ( - { ); }; -function ChatView({ +/** Thin wrapper that wires useChat hook to the shared ChatView */ +function ConnectedChat({ client, hubId, agentId, @@ -62,75 +57,9 @@ function ChatView({ agentId: string; onDisconnect: () => void; }) { - const { messages, streamingIds, isLoading, error, pendingApprovals, sendMessage, resolveApproval } = useChat({ client, hubId, agentId }); + const chat = useChat({ client, hubId, agentId }); - const mainRef = useRef(null); - const fadeStyle = useScrollFade(mainRef); - useAutoScroll(mainRef); - - return ( -
-
- -
- -
- {messages.length === 0 && pendingApprovals.length === 0 ? ( -
- Your Agent is ready -
- ) : ( - <> - - {pendingApprovals.length > 0 && ( -
- {pendingApprovals.map((approval) => ( - resolveApproval(approval.approvalId, decision)} - /> - ))} -
- )} - - )} -
- - {error && ( -
-
- {error.message} - -
-
- )} - -
- -
-
- ); + return ; } export default ChatPage; diff --git a/apps/web/package.json b/apps/web/package.json index 0badea42..4089488f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@multica/hooks": "workspace:*", "@multica/sdk": "workspace:*", "@multica/store": "workspace:*", "@multica/ui": "workspace:*", diff --git a/packages/hooks/package.json b/packages/hooks/package.json new file mode 100644 index 00000000..8c449cb4 --- /dev/null +++ b/packages/hooks/package.json @@ -0,0 +1,20 @@ +{ + "name": "@multica/hooks", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "dependencies": { + "@multica/sdk": "workspace:*", + "react": "catalog:", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@types/react": "catalog:", + "@types/uuid": "^11.0.0", + "typescript": "catalog:" + } +} diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts new file mode 100644 index 00000000..eb422289 --- /dev/null +++ b/packages/hooks/src/index.ts @@ -0,0 +1,15 @@ +export { useGatewayConnection } from "./use-gateway-connection"; +export type { + ConnectionIdentity, + PageState, + UseGatewayConnectionReturn, +} from "./use-gateway-connection"; + +export { useChat } from "./use-chat"; +export type { + Message, + ToolStatus, + ChatError, + PendingApproval, + UseChatReturn, +} from "./use-chat"; diff --git a/apps/web/hooks/use-chat.ts b/packages/hooks/src/use-chat.ts similarity index 97% rename from apps/web/hooks/use-chat.ts rename to packages/hooks/src/use-chat.ts index be639219..f195eedb 100644 --- a/apps/web/hooks/use-chat.ts +++ b/packages/hooks/src/use-chat.ts @@ -49,6 +49,7 @@ export interface UseChatReturn { messages: Message[]; streamingIds: Set; isLoading: boolean; + isLoadingHistory: boolean; error: ChatError | null; pendingApprovals: PendingApproval[]; sendMessage: (text: string) => void; @@ -75,6 +76,7 @@ export function useChat({ client, hubId, agentId }: UseChatOptions): UseChatRetu const [messages, setMessages] = useState([]); const [streamingIds, setStreamingIds] = useState>(new Set()); const [isLoading, setIsLoading] = useState(false); + const [isLoadingHistory, setIsLoadingHistory] = useState(true); const [error, setError] = useState(null); const [pendingApprovals, setPendingApprovals] = useState([]); // Keep a ref for use inside callbacks (avoids stale closures) @@ -141,6 +143,8 @@ export function useChat({ client, hubId, agentId }: UseChatOptions): UseChatRetu } } catch { // History fetch is best-effort + } finally { + setIsLoadingHistory(false); } } @@ -321,5 +325,5 @@ export function useChat({ client, hubId, agentId }: UseChatOptions): UseChatRetu [client, hubId, agentId], ); - return { messages, streamingIds, isLoading, error, pendingApprovals, sendMessage, resolveApproval }; + return { messages, streamingIds, isLoading, isLoadingHistory, error, pendingApprovals, sendMessage, resolveApproval }; } diff --git a/apps/web/hooks/use-gateway-connection.ts b/packages/hooks/src/use-gateway-connection.ts similarity index 100% rename from apps/web/hooks/use-gateway-connection.ts rename to packages/hooks/src/use-gateway-connection.ts diff --git a/packages/hooks/tsconfig.json b/packages/hooks/tsconfig.json new file mode 100644 index 00000000..a3034f69 --- /dev/null +++ b/packages/hooks/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ui/src/components/chat-input.tsx b/packages/ui/src/components/chat-input.tsx index 5d9d8f58..28e01c72 100644 --- a/packages/ui/src/components/chat-input.tsx +++ b/packages/ui/src/components/chat-input.tsx @@ -117,7 +117,7 @@ export const ChatInput = forwardRef(
diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx new file mode 100644 index 00000000..6cdd77d0 --- /dev/null +++ b/packages/ui/src/components/chat-view.tsx @@ -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; + 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(null); + const fadeStyle = useScrollFade(mainRef); + useAutoScroll(mainRef); + + return ( +
+
+ +
+ +
+ {isLoadingHistory && messages.length === 0 ? ( +
+ {/* User bubble */} +
+ +
+ {/* Assistant multi-line */} +
+ + + +
+ {/* Tool row */} +
+ +
+ {/* Assistant short reply */} +
+ + +
+ {/* User bubble */} +
+ +
+ {/* Assistant reply */} +
+ + + + +
+ {/* User bubble */} +
+ +
+ {/* Assistant reply */} +
+ + +
+
+ ) : messages.length === 0 && pendingApprovals.length === 0 ? ( +
+
+ +
+

Start a conversation

+

+ Type a message below to chat with your Agent +

+
+
+
+ ) : ( + <> + + {pendingApprovals.length > 0 && ( +
+ {pendingApprovals.map((approval) => ( + resolveApproval(approval.approvalId, decision)} + /> + ))} +
+ )} + + )} +
+ + {error && ( +
+
+ {error.message} + +
+
+ )} + +
+ +
+
+ ); +} diff --git a/apps/web/components/device-pairing.tsx b/packages/ui/src/components/device-pairing.tsx similarity index 92% rename from apps/web/components/device-pairing.tsx rename to packages/ui/src/components/device-pairing.tsx index 278665ed..dc5ab960 100644 --- a/apps/web/components/device-pairing.tsx +++ b/packages/ui/src/components/device-pairing.tsx @@ -13,8 +13,14 @@ import { 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"; -import type { ConnectionIdentity } from "@/hooks/use-gateway-connection"; + +export interface ConnectionIdentity { + gateway: string; + hubId: string; + agentId: string; +} export interface DevicePairingProps { connectionState: string; @@ -202,7 +208,10 @@ export function DevicePairing({ return (
-

Scan to connect

+
+ +

Scan to connect

+

Scan a Multica QR code to connect to your agent

@@ -216,9 +225,12 @@ export function DevicePairing({ return (
-

- {mode === "scan" ? "Scan to connect" : "Paste to connect"} -

+
+ +

+ {mode === "scan" ? "Scan to connect" : "Paste to connect"} +

+

{mode === "scan" ? "Scan a Multica QR code to connect to your agent" diff --git a/packages/ui/src/components/multica-icon.tsx b/packages/ui/src/components/multica-icon.tsx new file mode 100644 index 00000000..b8075638 --- /dev/null +++ b/packages/ui/src/components/multica-icon.tsx @@ -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 ( +