Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 112 additions & 61 deletions src/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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[],
Expand All @@ -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
Expand Down Expand Up @@ -132,10 +130,10 @@ const ChatPanel = ({ setIsOpen, viewport, onAction }: ChatPanelProps) => {
useState<ChatConversation[]>(initialConversations);
const [activeConversationId, setActiveConversationId] =
useState<string>(NEW_CHAT_ID);
const [draft, setDraft] = useState('');
const [draft, setDraft] = useState("");
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [connectionState, setConnectionState] =
useState<ChatConnectionState>('connecting');
useState<ChatConnectionState>("connecting");
const [isThinking, setIsThinking] = useState(false);

const listRef = useRef<HTMLDivElement>(null!);
Expand Down Expand Up @@ -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;
}

Expand All @@ -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");
}
}
};
Expand All @@ -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(),
};
Expand All @@ -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),
};
Expand All @@ -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) {
Expand All @@ -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;
Expand All @@ -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 (
<section
className="w-[720px]
Expand All @@ -419,9 +471,7 @@ const ChatPanel = ({ setIsOpen, viewport, onAction }: ChatPanelProps) => {
>
<ChatHeader
onClose={() => setIsOpen(false)}
conversationTitle={
activeConversation?.title ?? 'New chat'
}
conversationTitle={activeConversation?.title ?? "New chat"}
connectionLabel={connectionLabel}
isHistoryOpen={isHistoryOpen}
conversations={sortedConversations}
Expand All @@ -432,6 +482,7 @@ const ChatPanel = ({ setIsOpen, viewport, onAction }: ChatPanelProps) => {
}
onCloseHistory={() => setIsHistoryOpen(false)}
onSelectConversation={handleSelectConversation}
onNewChat={handleNewConversation}
/>
<ChatMessageList
messages={activeConversation?.messages ?? []}
Expand Down
12 changes: 11 additions & 1 deletion src/components/chat/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { History, X } from 'lucide-react';
import { History, SquarePen, X } from 'lucide-react';

import type { ChatConversation } from '../types';
import ChatHistoryMenu from './ChatHistoryMenu';
Expand All @@ -14,6 +14,7 @@ interface ChatHeaderProps {
onToggleHistory: () => void;
onCloseHistory: () => void;
onSelectConversation: (conversationId: string) => void;
onNewChat: () => void;
}

const ChatHeader = ({
Expand All @@ -27,6 +28,7 @@ const ChatHeader = ({
onToggleHistory,
onCloseHistory,
onSelectConversation,
onNewChat,
}: ChatHeaderProps) => {
return (
<header className="relative flex items-center justify-between border-b border-slate-900/10 px-5 py-4">
Expand All @@ -37,6 +39,14 @@ const ChatHeader = ({
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded-lg bg-slate-900/10 text-slate-900 transition hover:bg-slate-900/20"
onClick={onNewChat}
aria-label="New chat"
>
<SquarePen className="h-4 w-4" />
</button>
<button
type="button"
className="grid h-8 w-8 place-items-center rounded-lg bg-slate-900/10 text-slate-900 transition hover:bg-slate-900/20"
Expand Down