refactor(hooks,ui): extract useGatewayChat hook and update shared components
Move gateway-specific chat logic into dedicated useGatewayChat hook. useChat remains a pure state hook with no IO. Update ChatView props, remove legacy chat.tsx and connect-prompt.tsx. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
874de766ec
commit
7a21686505
9 changed files with 320 additions and 715 deletions
|
|
@ -13,3 +13,5 @@ export type {
|
|||
PendingApproval,
|
||||
UseChatReturn,
|
||||
} from "./use-chat";
|
||||
|
||||
export { useGatewayChat } from "./use-gateway-chat";
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import {
|
||||
type GatewayClient,
|
||||
type ContentBlock,
|
||||
type AgentEvent,
|
||||
type StreamPayload,
|
||||
type GetAgentMessagesResult,
|
||||
type AgentMessageItem,
|
||||
type ExecApprovalRequestPayload,
|
||||
type ApprovalDecision,
|
||||
StreamAction,
|
||||
ExecApprovalRequestAction,
|
||||
} from "@multica/sdk";
|
||||
|
||||
export type ToolStatus = "running" | "success" | "error" | "interrupted";
|
||||
|
|
@ -29,32 +26,18 @@ export interface Message {
|
|||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface UseChatOptions {
|
||||
client: GatewayClient;
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export interface ChatError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PendingApproval extends ExecApprovalRequestPayload {
|
||||
/** Timestamp when the request was received (for ordering) */
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
export interface UseChatReturn {
|
||||
messages: Message[];
|
||||
streamingIds: Set<string>;
|
||||
isLoading: boolean;
|
||||
isLoadingHistory: boolean;
|
||||
error: ChatError | null;
|
||||
pendingApprovals: PendingApproval[];
|
||||
sendMessage: (text: string) => void;
|
||||
resolveApproval: (approvalId: string, decision: ApprovalDecision) => void;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toContentBlocks(content: string | ContentBlock[]): ContentBlock[] {
|
||||
if (typeof content === "string") {
|
||||
|
|
@ -72,258 +55,178 @@ function extractContent(event: AgentEvent): ContentBlock[] {
|
|||
return Array.isArray(content) ? (content as ContentBlock[]) : [];
|
||||
}
|
||||
|
||||
export function useChat({ client, hubId, agentId }: UseChatOptions): UseChatReturn {
|
||||
// ---------------------------------------------------------------------------
|
||||
// useChat — pure state hook, no IO, no side effects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [streamingIds, setStreamingIds] = useState<Set<string>>(new Set());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
||||
const [error, setError] = useState<ChatError | null>(null);
|
||||
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([]);
|
||||
// Keep a ref for use inside callbacks (avoids stale closures)
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
const [error, setError] = useState<ChatError | null>(null);
|
||||
|
||||
// Fetch history on mount
|
||||
useEffect(() => {
|
||||
async function fetchHistory() {
|
||||
try {
|
||||
const result = await client.request<GetAgentMessagesResult>(
|
||||
hubId,
|
||||
"getAgentMessages",
|
||||
{ agentId, limit: 200 },
|
||||
);
|
||||
const isStreaming = streamingIds.size > 0;
|
||||
|
||||
// Build toolCallId → args lookup from assistant tool_use blocks
|
||||
const toolCallArgsMap = new Map<string, { name: string; args: Record<string, unknown> }>();
|
||||
for (const m of result.messages) {
|
||||
if (m.role === "assistant") {
|
||||
for (const block of m.content) {
|
||||
if (block.type === "toolCall") {
|
||||
toolCallArgsMap.set(block.id, { name: block.name, args: block.arguments });
|
||||
}
|
||||
}
|
||||
/** Load history: convert raw AgentMessageItem[] → Message[] */
|
||||
const setHistory = useCallback((raw: AgentMessageItem[], agentId: string) => {
|
||||
const toolCallArgsMap = new Map<string, { name: string; args: Record<string, unknown> }>();
|
||||
for (const m of raw) {
|
||||
if (m.role === "assistant") {
|
||||
for (const block of m.content) {
|
||||
if (block.type === "toolCall") {
|
||||
toolCallArgsMap.set(block.id, { name: block.name, args: block.arguments });
|
||||
}
|
||||
}
|
||||
|
||||
const loaded: Message[] = [];
|
||||
for (const m of result.messages) {
|
||||
if (m.role === "user") {
|
||||
loaded.push({
|
||||
id: uuidv7(),
|
||||
role: "user",
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
});
|
||||
} else if (m.role === "assistant") {
|
||||
loaded.push({
|
||||
id: uuidv7(),
|
||||
role: "assistant",
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
stopReason: m.stopReason,
|
||||
});
|
||||
} else if (m.role === "toolResult") {
|
||||
const callInfo = toolCallArgsMap.get(m.toolCallId);
|
||||
loaded.push({
|
||||
id: uuidv7(),
|
||||
role: "toolResult",
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
toolCallId: m.toolCallId,
|
||||
toolName: m.toolName,
|
||||
toolArgs: callInfo?.args,
|
||||
toolStatus: m.isError ? "error" : "success",
|
||||
isError: m.isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (loaded.length > 0) {
|
||||
setMessages(loaded);
|
||||
}
|
||||
} catch {
|
||||
// History fetch is best-effort
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchHistory();
|
||||
}, [client, hubId, agentId]);
|
||||
|
||||
// Listen for streaming events
|
||||
useEffect(() => {
|
||||
client.onMessage((msg) => {
|
||||
if (msg.action === StreamAction) {
|
||||
const payload = msg.payload as StreamPayload;
|
||||
const { event } = payload;
|
||||
|
||||
switch (event.type) {
|
||||
case "message_start": {
|
||||
const newMsg: Message = {
|
||||
id: payload.streamId,
|
||||
role: "assistant",
|
||||
content: [],
|
||||
agentId: payload.agentId,
|
||||
};
|
||||
const content = extractContent(event);
|
||||
if (content.length) newMsg.content = content;
|
||||
|
||||
setMessages((prev) => [...prev, newMsg]);
|
||||
setStreamingIds((prev) => new Set(prev).add(payload.streamId));
|
||||
setIsLoading(true);
|
||||
break;
|
||||
}
|
||||
case "message_update": {
|
||||
const content = extractContent(event);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === payload.streamId ? { ...m, content } : m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "message_end": {
|
||||
const content = extractContent(event);
|
||||
const stopReason =
|
||||
"message" in event
|
||||
? (event.message as { stopReason?: string })?.stopReason
|
||||
: undefined;
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.id === payload.streamId) return { ...m, content, stopReason };
|
||||
// Interrupt running tools belonging to the same agent
|
||||
if (
|
||||
m.role === "toolResult" &&
|
||||
m.toolStatus === "running" &&
|
||||
m.agentId === payload.agentId
|
||||
) {
|
||||
return { ...m, toolStatus: "interrupted" as ToolStatus };
|
||||
}
|
||||
return m;
|
||||
}),
|
||||
);
|
||||
setStreamingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(payload.streamId);
|
||||
return next;
|
||||
});
|
||||
setIsLoading(false);
|
||||
break;
|
||||
}
|
||||
case "tool_execution_start": {
|
||||
const toolMsg: Message = {
|
||||
id: uuidv7(),
|
||||
role: "toolResult",
|
||||
content: [],
|
||||
agentId: payload.agentId,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolArgs: event.args as Record<string, unknown> | undefined,
|
||||
toolStatus: "running",
|
||||
isError: false,
|
||||
};
|
||||
setMessages((prev) => [...prev, toolMsg]);
|
||||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === "toolResult" && m.toolCallId === event.toolCallId
|
||||
? {
|
||||
...m,
|
||||
toolStatus: (event.isError ? "error" : "success") as ToolStatus,
|
||||
isError: event.isError ?? false,
|
||||
content:
|
||||
event.result != null
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
typeof event.result === "string"
|
||||
? event.result
|
||||
: JSON.stringify(event.result),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tool_execution_update":
|
||||
break;
|
||||
}
|
||||
return;
|
||||
const loaded: Message[] = [];
|
||||
for (const m of raw) {
|
||||
if (m.role === "user") {
|
||||
loaded.push({ id: uuidv7(), role: "user", content: toContentBlocks(m.content), agentId });
|
||||
} else if (m.role === "assistant") {
|
||||
loaded.push({ id: uuidv7(), role: "assistant", content: toContentBlocks(m.content), agentId, stopReason: m.stopReason });
|
||||
} else if (m.role === "toolResult") {
|
||||
const callInfo = toolCallArgsMap.get(m.toolCallId);
|
||||
loaded.push({
|
||||
id: uuidv7(),
|
||||
role: "toolResult",
|
||||
content: toContentBlocks(m.content),
|
||||
agentId,
|
||||
toolCallId: m.toolCallId,
|
||||
toolName: m.toolName,
|
||||
toolArgs: callInfo?.args,
|
||||
toolStatus: m.isError ? "error" : "success",
|
||||
isError: m.isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Exec approval request from Hub
|
||||
if (msg.action === ExecApprovalRequestAction) {
|
||||
const payload = msg.payload as ExecApprovalRequestPayload;
|
||||
setPendingApprovals((prev) => [...prev, { ...payload, receivedAt: Date.now() }]);
|
||||
return;
|
||||
setMessages(loaded);
|
||||
}, []);
|
||||
|
||||
/** Add a user message */
|
||||
const addUserMessage = useCallback((text: string, agentId: string) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: uuidv7(), role: "user", content: [{ type: "text", text }], agentId },
|
||||
]);
|
||||
}, []);
|
||||
|
||||
/** Process a StreamPayload → update messages + streamingIds */
|
||||
const handleStream = useCallback((payload: StreamPayload) => {
|
||||
const { event } = payload;
|
||||
|
||||
switch (event.type) {
|
||||
case "message_start": {
|
||||
const newMsg: Message = {
|
||||
id: payload.streamId,
|
||||
role: "assistant",
|
||||
content: [],
|
||||
agentId: payload.agentId,
|
||||
};
|
||||
const content = extractContent(event);
|
||||
if (content.length) newMsg.content = content;
|
||||
|
||||
setMessages((prev) => [...prev, newMsg]);
|
||||
setStreamingIds((prev) => new Set(prev).add(payload.streamId));
|
||||
break;
|
||||
}
|
||||
|
||||
// Error from Hub (e.g. UNAUTHORIZED)
|
||||
if (msg.action === "error") {
|
||||
const errPayload = msg.payload as { code: string; message: string };
|
||||
setError({ code: errPayload.code, message: errPayload.message });
|
||||
return;
|
||||
case "message_update": {
|
||||
const content = extractContent(event);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === payload.streamId ? { ...m, content } : m)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "message_end": {
|
||||
const content = extractContent(event);
|
||||
const stopReason =
|
||||
"message" in event
|
||||
? (event.message as { stopReason?: string })?.stopReason
|
||||
: undefined;
|
||||
|
||||
// Direct (non-streaming) message
|
||||
const payload = msg.payload as { agentId?: string; content?: string };
|
||||
if (payload?.agentId && payload?.content) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.id === payload.streamId) return { ...m, content, stopReason };
|
||||
if (m.role === "toolResult" && m.toolStatus === "running" && m.agentId === payload.agentId) {
|
||||
return { ...m, toolStatus: "interrupted" as ToolStatus };
|
||||
}
|
||||
return m;
|
||||
}),
|
||||
);
|
||||
setStreamingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(payload.streamId);
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "tool_execution_start": {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: uuidv7(),
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: payload.content! }],
|
||||
agentId: payload.agentId!,
|
||||
role: "toolResult",
|
||||
content: [],
|
||||
agentId: payload.agentId,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolArgs: event.args as Record<string, unknown> | undefined,
|
||||
toolStatus: "running",
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
}
|
||||
});
|
||||
case "tool_execution_end": {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === "toolResult" && m.toolCallId === event.toolCallId
|
||||
? {
|
||||
...m,
|
||||
toolStatus: (event.isError ? "error" : "success") as ToolStatus,
|
||||
isError: event.isError ?? false,
|
||||
content:
|
||||
event.result != null
|
||||
? [{ type: "text" as const, text: typeof event.result === "string" ? event.result : JSON.stringify(event.result) }]
|
||||
: [],
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tool_execution_update":
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return () => {
|
||||
// Clear onMessage when unmounting
|
||||
client.onMessage(() => {});
|
||||
};
|
||||
}, [client, agentId]);
|
||||
/** Add pending approval */
|
||||
const addApproval = useCallback((payload: ExecApprovalRequestPayload) => {
|
||||
setPendingApprovals((prev) => [...prev, { ...payload, receivedAt: Date.now() }]);
|
||||
}, []);
|
||||
|
||||
const resolveApproval = useCallback(
|
||||
(approvalId: string, decision: ApprovalDecision) => {
|
||||
setPendingApprovals((prev) => prev.filter((a) => a.approvalId !== approvalId));
|
||||
client.request(hubId, "resolveExecApproval", { approvalId, decision }).catch(() => {
|
||||
// Best-effort — approval may have already expired
|
||||
});
|
||||
},
|
||||
[client, hubId],
|
||||
);
|
||||
/** Remove pending approval */
|
||||
const removeApproval = useCallback((approvalId: string) => {
|
||||
setPendingApprovals((prev) => prev.filter((a) => a.approvalId !== approvalId));
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: uuidv7(),
|
||||
role: "user",
|
||||
content: [{ type: "text", text: trimmed }],
|
||||
agentId,
|
||||
},
|
||||
]);
|
||||
|
||||
client.send(hubId, "message", { agentId, content: trimmed });
|
||||
setIsLoading(true);
|
||||
},
|
||||
[client, hubId, agentId],
|
||||
);
|
||||
|
||||
return { messages, streamingIds, isLoading, isLoadingHistory, error, pendingApprovals, sendMessage, resolveApproval };
|
||||
return {
|
||||
// Rendering state
|
||||
messages,
|
||||
streamingIds,
|
||||
isStreaming,
|
||||
pendingApprovals,
|
||||
error,
|
||||
// State control (for transport layer to call)
|
||||
setError,
|
||||
setHistory,
|
||||
addUserMessage,
|
||||
handleStream,
|
||||
addApproval,
|
||||
removeApproval,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseChatReturn = ReturnType<typeof useChat>;
|
||||
|
|
|
|||
87
packages/hooks/src/use-gateway-chat.ts
Normal file
87
packages/hooks/src/use-gateway-chat.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
type GatewayClient,
|
||||
type StreamPayload,
|
||||
type GetAgentMessagesResult,
|
||||
type ExecApprovalRequestPayload,
|
||||
type ApprovalDecision,
|
||||
StreamAction,
|
||||
ExecApprovalRequestAction,
|
||||
} from "@multica/sdk";
|
||||
import { useChat } from "./use-chat";
|
||||
|
||||
interface UseGatewayChatOptions {
|
||||
client: GatewayClient;
|
||||
hubId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function useGatewayChat({ client, hubId, agentId }: UseGatewayChatOptions) {
|
||||
const chat = useChat();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
||||
|
||||
// Fetch history
|
||||
useEffect(() => {
|
||||
client
|
||||
.request<GetAgentMessagesResult>(hubId, "getAgentMessages", { agentId, limit: 200 })
|
||||
.then((result) => chat.setHistory(result.messages, agentId))
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoadingHistory(false));
|
||||
}, [client, hubId, agentId]);
|
||||
|
||||
// Subscribe to events
|
||||
useEffect(() => {
|
||||
client.onMessage((msg) => {
|
||||
if (msg.action === StreamAction) {
|
||||
const payload = msg.payload as StreamPayload;
|
||||
chat.handleStream(payload);
|
||||
if (payload.event.type === "message_start") setIsLoading(true);
|
||||
if (payload.event.type === "message_end") setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (msg.action === ExecApprovalRequestAction) {
|
||||
chat.addApproval(msg.payload as ExecApprovalRequestPayload);
|
||||
return;
|
||||
}
|
||||
if (msg.action === "error") {
|
||||
chat.setError(msg.payload as { code: string; message: string });
|
||||
return;
|
||||
}
|
||||
});
|
||||
return () => { client.onMessage(() => {}); };
|
||||
}, [client]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
chat.addUserMessage(trimmed, agentId);
|
||||
chat.setError(null);
|
||||
client.send(hubId, "message", { agentId, content: trimmed });
|
||||
setIsLoading(true);
|
||||
},
|
||||
[client, hubId, agentId],
|
||||
);
|
||||
|
||||
const resolveApproval = useCallback(
|
||||
(approvalId: string, decision: ApprovalDecision) => {
|
||||
chat.removeApproval(approvalId);
|
||||
client.request(hubId, "resolveExecApproval", { approvalId, decision }).catch(() => {});
|
||||
},
|
||||
[client, hubId],
|
||||
);
|
||||
|
||||
return {
|
||||
messages: chat.messages,
|
||||
streamingIds: chat.streamingIds,
|
||||
isLoading,
|
||||
isLoadingHistory,
|
||||
error: chat.error,
|
||||
pendingApprovals: chat.pendingApprovals,
|
||||
sendMessage,
|
||||
resolveApproval,
|
||||
};
|
||||
}
|
||||
|
|
@ -72,50 +72,58 @@ export function useGatewayConnection(): UseGatewayConnectionReturn {
|
|||
|
||||
const connectToGateway = useCallback(
|
||||
(id: ConnectionIdentity, token?: string) => {
|
||||
const doConnect = () => {
|
||||
disconnectingRef.current = false;
|
||||
setPageState("connecting");
|
||||
setError(null);
|
||||
|
||||
const deviceId = getDeviceId();
|
||||
|
||||
const client = new GatewayClient({
|
||||
url: id.gateway,
|
||||
deviceId,
|
||||
deviceType: "client",
|
||||
hubId: id.hubId,
|
||||
...(token ? { token } : {}),
|
||||
})
|
||||
.onStateChange((state: ConnectionState) => {
|
||||
console.log("[GatewayConnection] state:", state);
|
||||
if (disconnectingRef.current) return;
|
||||
setConnectionState(state);
|
||||
if (state === "registered") {
|
||||
saveIdentity(id);
|
||||
setIdentity(id);
|
||||
setPageState("connected");
|
||||
}
|
||||
})
|
||||
.onError((err: Error) => {
|
||||
console.log("[GatewayConnection] error:", err.message);
|
||||
if (disconnectingRef.current) return;
|
||||
pairingKeyRef.current += 1;
|
||||
clearIdentity();
|
||||
setIdentity(null);
|
||||
setError(err.message);
|
||||
setPageState("not-connected");
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
})
|
||||
.onSendError((err) => {
|
||||
if (disconnectingRef.current) return;
|
||||
setError(err.error);
|
||||
});
|
||||
|
||||
clientRef.current = client;
|
||||
client.connect();
|
||||
};
|
||||
|
||||
// If there's an existing client, disconnect first and wait for Gateway to process
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
setTimeout(doConnect, 300);
|
||||
} else {
|
||||
doConnect();
|
||||
}
|
||||
|
||||
disconnectingRef.current = false;
|
||||
setPageState("connecting");
|
||||
setError(null);
|
||||
|
||||
const deviceId = getDeviceId();
|
||||
|
||||
const client = new GatewayClient({
|
||||
url: id.gateway,
|
||||
deviceId,
|
||||
deviceType: "client",
|
||||
hubId: id.hubId,
|
||||
...(token ? { token } : {}),
|
||||
})
|
||||
.onStateChange((state: ConnectionState) => {
|
||||
if (disconnectingRef.current) return;
|
||||
setConnectionState(state);
|
||||
if (state === "registered") {
|
||||
saveIdentity(id);
|
||||
setIdentity(id);
|
||||
setPageState("connected");
|
||||
}
|
||||
})
|
||||
.onError((err: Error) => {
|
||||
if (disconnectingRef.current) return;
|
||||
pairingKeyRef.current += 1;
|
||||
clearIdentity();
|
||||
setIdentity(null);
|
||||
setError(err.message);
|
||||
setPageState("not-connected");
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
})
|
||||
.onSendError((err) => {
|
||||
if (disconnectingRef.current) return;
|
||||
setError(err.error);
|
||||
});
|
||||
|
||||
clientRef.current = client;
|
||||
client.connect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
|
@ -123,15 +131,19 @@ export function useGatewayConnection(): UseGatewayConnectionReturn {
|
|||
// Try to reconnect with saved identity on mount
|
||||
useEffect(() => {
|
||||
const saved = loadIdentity();
|
||||
console.log("[GatewayConnection] mount, saved identity:", saved);
|
||||
if (!saved) {
|
||||
setPageState("not-connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIdentity(saved);
|
||||
connectToGateway(saved);
|
||||
// Delay reconnection — if a previous socket just disconnected (e.g. StrictMode
|
||||
// cleanup or page navigation), the Gateway needs time to process it
|
||||
const timer = setTimeout(() => connectToGateway(saved), 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export interface ChatViewProps {
|
|||
pendingApprovals: ChatViewApproval[];
|
||||
sendMessage: (text: string) => void;
|
||||
resolveApproval: (approvalId: string, decision: "allow-once" | "allow-always" | "deny") => void;
|
||||
onDisconnect: () => void;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
|
|
@ -54,14 +54,16 @@ export function ChatView({
|
|||
|
||||
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>
|
||||
{onDisconnect && (
|
||||
<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 ? (
|
||||
|
|
@ -144,14 +146,16 @@ export function ChatView({
|
|||
<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>
|
||||
{onDisconnect && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
className="shrink-0 text-xs h-7 px-2.5"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
export function Chat() {
|
||||
const { loading } = useAutoConnect()
|
||||
|
||||
const agentId = useConnectionStore((s) => s.agentId)
|
||||
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) => {
|
||||
const { hubId, agentId, send, connectionState } = useConnectionStore.getState()
|
||||
if (connectionState !== "registered" || !hubId || !agentId) return
|
||||
useMessagesStore.getState().sendMessage(text, { hubId, agentId, send })
|
||||
}, [])
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
useConnectionStore.getState().disconnect()
|
||||
}, [])
|
||||
|
||||
const mainRef = useRef<HTMLElement>(null)
|
||||
const fadeStyle = useScrollFade(mainRef)
|
||||
useAutoScroll(mainRef)
|
||||
|
||||
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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
|
||||
{loading ? (
|
||||
<ChatSkeleton />
|
||||
) : !isConnected ? (
|
||||
<ConnectPrompt />
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Your Agent is ready
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} streamingIds={streamingIds} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Error banner */}
|
||||
{lastError && (
|
||||
<div className="px-4 py-2 max-w-4xl mx-auto w-full" role="alert" aria-live="polite">
|
||||
<div className="rounded-md bg-destructive/10 text-destructive text-sm px-3 py-2 flex items-center justify-between">
|
||||
<span>{lastError.message} ({lastError.code})</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss error"
|
||||
onClick={() => useConnectionStore.setState({ lastError: null })}
|
||||
className="text-destructive/60 hover:text-destructive ml-2 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded outline-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="w-full p-2 pt-1 max-w-4xl mx-auto">
|
||||
<ChatInput
|
||||
onSubmit={handleSend}
|
||||
disabled={!isConnected}
|
||||
placeholder={!isConnected ? "Scan QR code to get started" : "Ask your Agent..."}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
"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 {
|
||||
useConnectionStore,
|
||||
parseConnectionCode,
|
||||
saveConnection,
|
||||
} from "@multica/store";
|
||||
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 { 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;
|
||||
validatingRef.current = true;
|
||||
try {
|
||||
const info = parseConnectionCode(trimmed);
|
||||
setPasteState("success");
|
||||
navigator.vibrate?.(50);
|
||||
// Let the user see the success state before connecting
|
||||
setTimeout(() => {
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
}, 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;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-validate on paste
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
const text = e.clipboardData.getData("text");
|
||||
if (!text.trim()) return;
|
||||
// Let the textarea update visually first, then validate
|
||||
setTimeout(() => tryConnect(text), 50);
|
||||
},
|
||||
[tryConnect],
|
||||
);
|
||||
|
||||
// Promise-based handler for QrScannerView
|
||||
const handleScanResult = useCallback(async (data: string) => {
|
||||
const info = parseConnectionCode(data);
|
||||
saveConnection(info);
|
||||
useConnectionStore.getState().connect(info);
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
|
||||
<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 Multica QR code to start chatting
|
||||
</p>
|
||||
</div>
|
||||
<QrScannerView onResult={handleScanResult} fullscreen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: tab toggle (scan / paste), same-size panels
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-base font-medium">
|
||||
{mode === "scan" ? "Scan to start" : "Paste to start"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{mode === "scan"
|
||||
? "Scan a Multica QR code to start chatting"
|
||||
: "Paste a Multica connection code to start chatting"}
|
||||
</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 — same max-width for both modes */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -221,7 +221,7 @@ export function DevicePairing({
|
|||
// Mobile: scanner only
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4">
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4 mb-28">
|
||||
<PairingHeader
|
||||
title="Scan to connect"
|
||||
description="Scan a Multica QR code to connect to your agent"
|
||||
|
|
@ -233,7 +233,7 @@ export function DevicePairing({
|
|||
|
||||
// Desktop: tab toggle (scan / paste)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4 mb-14 sm:mb-28">
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-4 mb-28">
|
||||
<PairingHeader
|
||||
title={mode === "scan" ? "Scan to connect" : "Paste to connect"}
|
||||
description={mode === "scan"
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export const MessageList = memo(function MessageList({ messages, streamingIds }:
|
|||
}, [messages])
|
||||
|
||||
return (
|
||||
<div className="relative px-4 py-6 max-w-4xl mx-auto">
|
||||
<div className="relative p-6 max-w-4xl mx-auto">
|
||||
{messages.map((msg) => {
|
||||
// ToolResult messages → render as tool execution item
|
||||
if (msg.role === "toolResult") {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue