diff --git a/src/components/chat/ChatPanel.tsx b/src/components/chat/ChatPanel.tsx index d969f0c..4b74542 100644 --- a/src/components/chat/ChatPanel.tsx +++ b/src/components/chat/ChatPanel.tsx @@ -1,34 +1,34 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import type { FormEventHandler } from 'react'; - -import type { ViewportBBox } from '@components/map/MapView'; - -import type { ChatAction } from './ChatDock'; -import { createChatHistoryClient } from './clients/history/createChatHistoryClient'; -import { createMockChatHistoryClient } from './clients/history/mockChatHistoryClient'; -import type { ChatHistoryClient } from './clients/history/types'; -import { createMockChatRealtimeClient } from './clients/realtime/mockChatRealtimeClient'; -import type { ChatRealtimeClient } from './clients/realtime/types'; -import ChatInput from './components/ChatInput'; -import ChatHeader from './components/ChatHeader'; -import ChatMessageList from './components/ChatMessageList'; -import { getChatRuntimeConfig } from './config'; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { FormEventHandler } from "react"; + +import type { ViewportBBox } from "@components/map/MapView"; + +import type { ChatAction } from "./ChatDock"; +import { createChatHistoryClient } from "./clients/history/createChatHistoryClient"; +import { createMockChatHistoryClient } from "./clients/history/mockChatHistoryClient"; +import type { ChatHistoryClient } from "./clients/history/types"; +import { createMockChatRealtimeClient } from "./clients/realtime/mockChatRealtimeClient"; +import type { ChatRealtimeClient } from "./clients/realtime/types"; +import ChatInput from "./components/ChatInput"; +import ChatHeader from "./components/ChatHeader"; +import ChatMessageList from "./components/ChatMessageList"; +import { getChatRuntimeConfig } from "./config"; import type { ChatInboundEventEnvelope, ChatOutboundEventEnvelope, -} from './contracts'; +} from "./contracts"; import { mapConversationDetailToUiConversation, mapMessageRecordToUiMessage, mapUiMessageToMessageRecord, -} from './mappers'; -import mockConversations from './mockConversations'; +} from "./mappers"; +import mockConversations from "./mockConversations"; import type { ChatConnectionState, ChatConversation, ChatMessage, Sender, -} from './types'; +} from "./types"; interface ChatPanelProps { setIsOpen: (isOpen: boolean) => void; @@ -62,10 +62,10 @@ const initialConversations = sortConversationsByUpdatedAt( ); const buildConnectionLabel = (state: ChatConnectionState): string => { - if (state === 'connecting') return 'Connecting...'; - if (state === 'connected') return 'Mock'; - if (state === 'error') return 'Connection error'; - return 'Disconnected'; + if (state === "connecting") return "Connecting..."; + if (state === "connected") return "Mock"; + if (state === "error") return "Connection error"; + return "Disconnected"; }; let messageCounter = 0; @@ -74,7 +74,7 @@ const createMessageId = (sender: Sender): string => { return `${sender}-${Date.now()}-${messageCounter}`; }; -const NEW_CHAT_ID = ''; +const NEW_CHAT_ID = ""; const appendMessageToConversation = ( conversations: ChatConversation[], @@ -93,9 +93,7 @@ const appendMessageToConversation = ( }; }); -const createNewConversation = ( - firstMessage: ChatMessage, -): ChatConversation => { +const createNewConversation = (firstMessage: ChatMessage): ChatConversation => { const id = `conv-new-${Date.now()}`; const title = firstMessage.text.length > 40 @@ -132,10 +130,10 @@ const ChatPanel = ({ setIsOpen, viewport, onAction }: ChatPanelProps) => { useState(initialConversations); const [activeConversationId, setActiveConversationId] = useState(NEW_CHAT_ID); - const [draft, setDraft] = useState(''); + const [draft, setDraft] = useState(""); const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [connectionState, setConnectionState] = - useState('connecting'); + useState("connecting"); const [isThinking, setIsThinking] = useState(false); const listRef = useRef(null!); @@ -241,15 +239,15 @@ const ChatPanel = ({ setIsOpen, viewport, onAction }: ChatPanelProps) => { const client: ChatRealtimeClient = createMockChatRealtimeClient(); realtimeClientRef.current = client; - setConnectionState('connecting'); + setConnectionState("connecting"); const handleInboundEvent = (event: ChatInboundEventEnvelope) => { - if (event.type === 'chat.error') { - setConnectionState('error'); + if (event.type === "chat.error") { + setConnectionState("error"); return; } - if (event.type !== 'chat.agent_message') { + if (event.type !== "chat.agent_message") { return; } @@ -274,13 +272,13 @@ const ChatPanel = ({ setIsOpen, viewport, onAction }: ChatPanelProps) => { try { await client.connect(); if (!isCancelled) { - setConnectionState('connected'); + setConnectionState("connected"); } } catch { unsubscribe(); client.disconnect(); if (!isCancelled) { - setConnectionState('error'); + setConnectionState("error"); } } }; @@ -304,8 +302,8 @@ const ChatPanel = ({ setIsOpen, viewport, onAction }: ChatPanelProps) => { } const userMessage: ChatMessage = { - id: createMessageId('user'), - sender: 'user', + id: createMessageId("user"), + sender: "user", text: trimmed, sentAt: new Date().toISOString(), }; @@ -321,7 +319,7 @@ const ChatPanel = ({ setIsOpen, viewport, onAction }: ChatPanelProps) => { setActiveConversationId(newConversation.id); const outboundEvent: ChatOutboundEventEnvelope = { - type: 'chat.user_message', + type: "chat.user_message", conversation_id: newConversation.id, message: mapUiMessageToMessageRecord(userMessage), }; @@ -336,14 +334,14 @@ const ChatPanel = ({ setIsOpen, viewport, onAction }: ChatPanelProps) => { ); const outboundEvent: ChatOutboundEventEnvelope = { - type: 'chat.user_message', + type: "chat.user_message", conversation_id: activeConversation.id, message: mapUiMessageToMessageRecord(userMessage), }; realtimeClientRef.current?.send(outboundEvent); } - setDraft(''); + setDraft(""); const { apiBaseUrl } = getChatRuntimeConfig(); if (apiBaseUrl) { @@ -360,34 +358,78 @@ const ChatPanel = ({ setIsOpen, viewport, onAction }: ChatPanelProps) => { body.viewport = viewport; } fetch(`${apiBaseUrl}/chat/message`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) - .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))) - .then((data: { conversation_id: number; reply: string; actions?: ChatAction[] }) => { - backendConversationIdRef.current = data.conversation_id; - const agentMessage: ChatMessage = { - id: createMessageId('agent'), - sender: 'agent', - text: data.reply, + .then((r) => { + if (r.ok) { + return r.json(); + } + const retryAfter = + r.status === 503 ? r.headers.get("Retry-After") : null; + const error = new Error(`${r.status}`); + ( + error as Error & { retryAfter: string | null } + ).retryAfter = retryAfter; + return Promise.reject(error); + }) + .then( + (data: { + conversation_id: number; + reply: string; + actions?: ChatAction[]; + }) => { + backendConversationIdRef.current = data.conversation_id; + const agentMessage: ChatMessage = { + id: createMessageId("agent"), + sender: "agent", + text: data.reply, + sentAt: new Date().toISOString(), + }; + setConversations((current) => + appendMessageToConversation( + current, + targetConversationId, + agentMessage, + ), + ); + if (data.actions && Array.isArray(data.actions)) { + for (const action of data.actions) { + onAction?.(action); + } + } + }, + ) + .catch((err: unknown) => { + let errorText = + "Unable to reach the chat service. Please try again later."; + if (err instanceof Error && "retryAfter" in err) { + const raw = ( + err as Error & { retryAfter: string | null } + ).retryAfter; + const parsed = parseInt(raw ?? "", 10); + if ( + !Number.isNaN(parsed) && + parsed > 0 && + parsed < 3600 + ) { + errorText += ` (retry after ${parsed}s)`; + } + } + const errorMessage: ChatMessage = { + id: createMessageId("agent"), + sender: "agent", + text: errorText, sentAt: new Date().toISOString(), }; setConversations((current) => appendMessageToConversation( current, targetConversationId, - agentMessage, + errorMessage, ), ); - if (data.actions && Array.isArray(data.actions)) { - for (const action of data.actions) { - onAction?.(action); - } - } - }) - .catch(() => { - // Backend unavailable — allow mock client to respond as fallback }) .finally(() => { inflightCountRef.current -= 1; @@ -409,6 +451,16 @@ const ChatPanel = ({ setIsOpen, viewport, onAction }: ChatPanelProps) => { setIsHistoryOpen(false); }; + const handleNewConversation = () => { + // Clear any state from an in-flight request so the new thread + // doesn't inherit a stuck thinking indicator or suppressed mock. + inflightCountRef.current = 0; + suppressMockRef.current = false; + setIsThinking(false); + setActiveConversationId(NEW_CHAT_ID); + setIsHistoryOpen(false); + }; + return (
{ } onCloseHistory={() => setIsHistoryOpen(false)} onSelectConversation={handleSelectConversation} + onNewChat={handleNewConversation} /> void; onCloseHistory: () => void; onSelectConversation: (conversationId: string) => void; + onNewChat: () => void; } const ChatHeader = ({ @@ -27,6 +28,7 @@ const ChatHeader = ({ onToggleHistory, onCloseHistory, onSelectConversation, + onNewChat, }: ChatHeaderProps) => { return (
@@ -37,6 +39,14 @@ const ChatHeader = ({

+