fix: chat WS relay URL + chat UX improvements

- Fix docker-compose to read GATEWAY_WS_URL (was VITE_WS_URL, never set)
- Fix gateway-relay.ts default to ws.hammer.donovankelly.xyz
- Fix Elysia TS errors in error handlers (cast to any)
- Add thinking/typing indicator in chat (bouncing dots)
- Add message timestamps (tap to show)
- Add thread renaming (double-click thread name)
- Auto-resize chat input textarea
This commit is contained in:
2026-01-29 08:34:45 +00:00
parent 0f084704ee
commit 819649c8c7
7 changed files with 126 additions and 31 deletions

View File

@@ -21,6 +21,7 @@ function ThreadList({
activeThread,
onSelect,
onCreate,
onRename,
onDelete,
onClose,
}: {
@@ -28,9 +29,25 @@ function ThreadList({
activeThread: string | null;
onSelect: (key: string) => void;
onCreate: () => void;
onRename?: (key: string, name: string) => void;
onDelete?: (key: string) => void;
onClose?: () => void;
}) {
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const startRename = (key: string, currentName: string) => {
setEditingKey(key);
setEditName(currentName);
};
const commitRename = () => {
if (editingKey && editName.trim() && onRename) {
onRename(editingKey, editName.trim());
}
setEditingKey(null);
};
return (
<div className="w-full sm:w-64 bg-white border-r border-gray-200 flex flex-col h-full">
<div className="p-3 border-b border-gray-100 flex items-center justify-between">
@@ -71,15 +88,36 @@ function ThreadList({
}`}
onClick={() => onSelect(thread.sessionKey)}
>
<div className="text-sm font-medium text-gray-800 truncate pr-6">
{thread.name}
</div>
{editingKey === thread.sessionKey ? (
<input
autoFocus
className="text-sm font-medium text-gray-800 w-full bg-white border border-amber-300 rounded px-1 py-0.5 outline-none"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") setEditingKey(null);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<div
className="text-sm font-medium text-gray-800 truncate pr-6"
onDoubleClick={(e) => {
e.stopPropagation();
startRename(thread.sessionKey, thread.name);
}}
>
{thread.name}
</div>
)}
{thread.lastMessage && (
<div className="text-xs text-gray-400 truncate mt-0.5">
{thread.lastMessage}
</div>
)}
{onDelete && (
{onDelete && editingKey !== thread.sessionKey && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -101,7 +139,14 @@ function ThreadList({
);
}
function formatTimestamp(ts?: number): string {
if (!ts) return "";
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function MessageBubble({ msg }: { msg: ChatMessage }) {
const [showTime, setShowTime] = useState(false);
const isUser = msg.role === "user";
const isSystem = msg.role === "system";
@@ -116,37 +161,65 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
}
return (
<div className={`flex ${isUser ? "justify-end" : "justify-start"} mb-3`}>
<div
className={`flex ${isUser ? "justify-end" : "justify-start"} mb-3 group`}
onClick={() => setShowTime(!showTime)}
>
{!isUser && (
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
🔨
</div>
)}
<div
className={`max-w-[75%] rounded-2xl px-4 py-2.5 ${
isUser
? "bg-blue-500 text-white rounded-br-md"
: "bg-gray-100 text-gray-800 rounded-bl-md"
}`}
>
{isUser ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{msg.content}</p>
) : (
<div className="text-sm leading-relaxed prose prose-sm prose-gray max-w-none [&_pre]:bg-gray-800 [&_pre]:text-gray-100 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_pre]:text-xs [&_code]:bg-gray-200 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_p]:mb-2 [&_p:last-child]:mb-0 [&_ul]:mb-2 [&_ol]:mb-2 [&_li]:mb-0.5 [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_a]:text-amber-600 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-gray-300 [&_blockquote]:pl-3 [&_blockquote]:text-gray-500">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
<div className="flex flex-col">
<div
className={`max-w-[75vw] sm:max-w-[60vw] rounded-2xl px-4 py-2.5 ${
isUser
? "bg-blue-500 text-white rounded-br-md"
: "bg-gray-100 text-gray-800 rounded-bl-md"
}`}
>
{isUser ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{msg.content}</p>
) : (
<div className="text-sm leading-relaxed prose prose-sm prose-gray max-w-none [&_pre]:bg-gray-800 [&_pre]:text-gray-100 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_pre]:text-xs [&_code]:bg-gray-200 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_p]:mb-2 [&_p:last-child]:mb-0 [&_ul]:mb-2 [&_ol]:mb-2 [&_li]:mb-0.5 [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_a]:text-amber-600 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-gray-300 [&_blockquote]:pl-3 [&_blockquote]:text-gray-500">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
)}
</div>
{showTime && msg.timestamp && (
<span className={`text-[10px] text-gray-400 mt-0.5 ${isUser ? "text-right" : "text-left"}`}>
{formatTimestamp(msg.timestamp)}
</span>
)}
</div>
</div>
);
}
function ThinkingIndicator() {
return (
<div className="flex justify-start mb-3">
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
🔨
</div>
<div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
<div className="flex gap-1.5">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
</div>
</div>
);
}
function ChatArea({
messages,
loading,
streaming,
thinking,
streamText,
onSend,
onAbort,
@@ -155,6 +228,7 @@ function ChatArea({
messages: ChatMessage[];
loading: boolean;
streaming: boolean;
thinking: boolean;
streamText: string;
onSend: (msg: string) => void;
onAbort: () => void;
@@ -166,7 +240,15 @@ function ChatArea({
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streamText]);
}, [messages, streamText, thinking]);
// Auto-resize textarea
const autoResize = useCallback(() => {
const el = inputRef.current;
if (!el) return;
el.style.height = "42px";
el.style.height = Math.min(el.scrollHeight, 128) + "px";
}, []);
const handleSend = () => {
const text = input.trim();
@@ -204,6 +286,7 @@ function ChatArea({
{streaming && streamText && (
<MessageBubble msg={{ role: "assistant", content: streamText }} />
)}
{thinking && !streaming && <ThinkingIndicator />}
</>
)}
<div ref={messagesEndRef} />
@@ -227,7 +310,7 @@ function ChatArea({
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={handleKeyDown}
placeholder={connected ? "Type a message..." : "Connecting..."}
disabled={!connected}
@@ -271,6 +354,7 @@ export function ChatPage() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const [thinking, setThinking] = useState(false);
const [streamText, setStreamText] = useState("");
const [showThreads, setShowThreads] = useState(false);
@@ -295,6 +379,7 @@ export function ChatPage() {
if (payload.sessionKey !== activeThread) return;
if (payload.state === "delta" && payload.message?.content) {
setThinking(false);
setStreaming(true);
const textParts = payload.message.content
.filter((c: any) => c.type === "text")
@@ -304,13 +389,14 @@ export function ChatPage() {
setStreamText((prev) => prev + textParts);
}
} else if (payload.state === "final") {
setThinking(false);
if (payload.message?.content) {
const text = payload.message.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("");
if (text) {
setMessages((prev) => [...prev, { role: "assistant", content: text }]);
setMessages((prev) => [...prev, { role: "assistant", content: text, timestamp: Date.now() }]);
setThreads((prev) =>
prev.map((t) =>
t.sessionKey === activeThread
@@ -323,6 +409,7 @@ export function ChatPage() {
setStreaming(false);
setStreamText("");
} else if (payload.state === "aborted" || payload.state === "error") {
setThinking(false);
setStreaming(false);
if (streamText) {
setMessages((prev) => [...prev, { role: "assistant", content: streamText }]);
@@ -385,6 +472,10 @@ export function ChatPage() {
setMessages([]);
};
const handleRenameThread = (key: string, name: string) => {
setThreads((prev) => prev.map((t) => (t.sessionKey === key ? { ...t, name } : t)));
};
const handleDeleteThread = (key: string) => {
setThreads((prev) => prev.filter((t) => t.sessionKey !== key));
if (activeThread === key) {
@@ -397,7 +488,8 @@ export function ChatPage() {
const handleSend = async (text: string) => {
if (!activeThread) return;
setMessages((prev) => [...prev, { role: "user", content: text }]);
setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
setThinking(true);
setThreads((prev) =>
prev.map((t) =>
t.sessionKey === activeThread
@@ -410,6 +502,7 @@ export function ChatPage() {
await client.chatSend(activeThread, text);
} catch (e) {
console.error("Failed to send:", e);
setThinking(false);
setMessages((prev) => [
...prev,
{ role: "system", content: "Failed to send message. Please try again." },
@@ -494,6 +587,7 @@ export function ChatPage() {
handleCreateThread();
setShowThreads(false);
}}
onRename={handleRenameThread}
onDelete={handleDeleteThread}
onClose={() => setShowThreads(false)}
/>
@@ -504,6 +598,7 @@ export function ChatPage() {
messages={messages}
loading={loading}
streaming={streaming}
thinking={thinking}
streamText={streamText}
onSend={handleSend}
onAbort={handleAbort}