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

@@ -140,7 +140,7 @@ const app = new Elysia()
.get("/health", () => ({ status: "ok", service: "hammer-queue" })) .get("/health", () => ({ status: "ok", service: "hammer-queue" }))
.onError(({ error, set }) => { .onError(({ error, set }) => {
const msg = error?.message || String(error); const msg = (error as any)?.message || String(error);
if (msg === "Unauthorized") { if (msg === "Unauthorized") {
set.status = 401; set.status = 401;
return { error: "Unauthorized" }; return { error: "Unauthorized" };

View File

@@ -10,8 +10,8 @@
* (BetterAuth) (relay) (token auth) * (BetterAuth) (relay) (token auth)
*/ */
const GATEWAY_URL = process.env.GATEWAY_WS_URL || process.env.VITE_WS_URL || "wss://hammer.donovankelly.xyz"; const GATEWAY_URL = process.env.GATEWAY_WS_URL || "wss://ws.hammer.donovankelly.xyz";
const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || process.env.VITE_WS_TOKEN || ""; const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || "";
type GatewayState = "disconnected" | "connecting" | "connected"; type GatewayState = "disconnected" | "connecting" | "connected";
type MessageHandler = (msg: any) => void; type MessageHandler = (msg: any) => void;

View File

@@ -19,7 +19,7 @@ async function requireAdmin(request: Request, headers: Record<string, string | u
export const adminRoutes = new Elysia({ prefix: "/api/admin" }) export const adminRoutes = new Elysia({ prefix: "/api/admin" })
.onError(({ error, set }) => { .onError(({ error, set }) => {
const msg = error?.message || String(error); const msg = (error as any)?.message || String(error);
if (msg === "Unauthorized") { if (msg === "Unauthorized") {
set.status = 401; set.status = 401;
return { error: "Unauthorized" }; return { error: "Unauthorized" };

View File

@@ -21,7 +21,7 @@ async function requireSessionOrBearer(
export const projectRoutes = new Elysia({ prefix: "/api/projects" }) export const projectRoutes = new Elysia({ prefix: "/api/projects" })
.onError(({ error, set }) => { .onError(({ error, set }) => {
const msg = error?.message || String(error); const msg = (error as any)?.message || String(error);
if (msg === "Unauthorized") { if (msg === "Unauthorized") {
set.status = 401; set.status = 401;
return { error: "Unauthorized" }; return { error: "Unauthorized" };

View File

@@ -104,7 +104,7 @@ async function resolveTask(idOrNumber: string) {
export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
.onError(({ error, set }) => { .onError(({ error, set }) => {
const msg = error?.message || String(error); const msg = (error as any)?.message || String(error);
if (msg === "Unauthorized") { if (msg === "Unauthorized") {
set.status = 401; set.status = 401;
return { error: "Unauthorized" }; return { error: "Unauthorized" };

View File

@@ -25,7 +25,7 @@ services:
COOKIE_DOMAIN: .donovankelly.xyz COOKIE_DOMAIN: .donovankelly.xyz
CLAWDBOT_HOOK_URL: ${CLAWDBOT_HOOK_URL:-https://hammer.donovankelly.xyz/hooks/agent} CLAWDBOT_HOOK_URL: ${CLAWDBOT_HOOK_URL:-https://hammer.donovankelly.xyz/hooks/agent}
CLAWDBOT_HOOK_TOKEN: ${CLAWDBOT_HOOK_TOKEN} CLAWDBOT_HOOK_TOKEN: ${CLAWDBOT_HOOK_TOKEN}
GATEWAY_WS_URL: ${VITE_WS_URL:-wss://hammer.donovankelly.xyz} GATEWAY_WS_URL: ${GATEWAY_WS_URL:-wss://ws.hammer.donovankelly.xyz}
GATEWAY_WS_TOKEN: ${GATEWAY_WS_TOKEN} GATEWAY_WS_TOKEN: ${GATEWAY_WS_TOKEN}
PORT: "3100" PORT: "3100"
depends_on: depends_on:

View File

@@ -21,6 +21,7 @@ function ThreadList({
activeThread, activeThread,
onSelect, onSelect,
onCreate, onCreate,
onRename,
onDelete, onDelete,
onClose, onClose,
}: { }: {
@@ -28,9 +29,25 @@ function ThreadList({
activeThread: string | null; activeThread: string | null;
onSelect: (key: string) => void; onSelect: (key: string) => void;
onCreate: () => void; onCreate: () => void;
onRename?: (key: string, name: string) => void;
onDelete?: (key: string) => void; onDelete?: (key: string) => void;
onClose?: () => 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 ( return (
<div className="w-full sm:w-64 bg-white border-r border-gray-200 flex flex-col h-full"> <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"> <div className="p-3 border-b border-gray-100 flex items-center justify-between">
@@ -71,15 +88,36 @@ function ThreadList({
}`} }`}
onClick={() => onSelect(thread.sessionKey)} onClick={() => onSelect(thread.sessionKey)}
> >
<div className="text-sm font-medium text-gray-800 truncate pr-6"> {editingKey === thread.sessionKey ? (
{thread.name} <input
</div> 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 && ( {thread.lastMessage && (
<div className="text-xs text-gray-400 truncate mt-0.5"> <div className="text-xs text-gray-400 truncate mt-0.5">
{thread.lastMessage} {thread.lastMessage}
</div> </div>
)} )}
{onDelete && ( {onDelete && editingKey !== thread.sessionKey && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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 }) { function MessageBubble({ msg }: { msg: ChatMessage }) {
const [showTime, setShowTime] = useState(false);
const isUser = msg.role === "user"; const isUser = msg.role === "user";
const isSystem = msg.role === "system"; const isSystem = msg.role === "system";
@@ -116,37 +161,65 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
} }
return ( 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 && ( {!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 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>
)} )}
<div <div className="flex flex-col">
className={`max-w-[75%] rounded-2xl px-4 py-2.5 ${ <div
isUser className={`max-w-[75vw] sm:max-w-[60vw] rounded-2xl px-4 py-2.5 ${
? "bg-blue-500 text-white rounded-br-md" isUser
: "bg-gray-100 text-gray-800 rounded-bl-md" ? "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> {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]}> <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">
{msg.content} <ReactMarkdown remarkPlugins={[remarkGfm]}>
</ReactMarkdown> {msg.content}
</div> </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>
</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({ function ChatArea({
messages, messages,
loading, loading,
streaming, streaming,
thinking,
streamText, streamText,
onSend, onSend,
onAbort, onAbort,
@@ -155,6 +228,7 @@ function ChatArea({
messages: ChatMessage[]; messages: ChatMessage[];
loading: boolean; loading: boolean;
streaming: boolean; streaming: boolean;
thinking: boolean;
streamText: string; streamText: string;
onSend: (msg: string) => void; onSend: (msg: string) => void;
onAbort: () => void; onAbort: () => void;
@@ -166,7 +240,15 @@ function ChatArea({
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); 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 handleSend = () => {
const text = input.trim(); const text = input.trim();
@@ -204,6 +286,7 @@ function ChatArea({
{streaming && streamText && ( {streaming && streamText && (
<MessageBubble msg={{ role: "assistant", content: streamText }} /> <MessageBubble msg={{ role: "assistant", content: streamText }} />
)} )}
{thinking && !streaming && <ThinkingIndicator />}
</> </>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
@@ -227,7 +310,7 @@ function ChatArea({
<textarea <textarea
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={connected ? "Type a message..." : "Connecting..."} placeholder={connected ? "Type a message..." : "Connecting..."}
disabled={!connected} disabled={!connected}
@@ -271,6 +354,7 @@ export function ChatPage() {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [thinking, setThinking] = useState(false);
const [streamText, setStreamText] = useState(""); const [streamText, setStreamText] = useState("");
const [showThreads, setShowThreads] = useState(false); const [showThreads, setShowThreads] = useState(false);
@@ -295,6 +379,7 @@ export function ChatPage() {
if (payload.sessionKey !== activeThread) return; if (payload.sessionKey !== activeThread) return;
if (payload.state === "delta" && payload.message?.content) { if (payload.state === "delta" && payload.message?.content) {
setThinking(false);
setStreaming(true); setStreaming(true);
const textParts = payload.message.content const textParts = payload.message.content
.filter((c: any) => c.type === "text") .filter((c: any) => c.type === "text")
@@ -304,13 +389,14 @@ export function ChatPage() {
setStreamText((prev) => prev + textParts); setStreamText((prev) => prev + textParts);
} }
} else if (payload.state === "final") { } else if (payload.state === "final") {
setThinking(false);
if (payload.message?.content) { if (payload.message?.content) {
const text = payload.message.content const text = payload.message.content
.filter((c: any) => c.type === "text") .filter((c: any) => c.type === "text")
.map((c: any) => c.text) .map((c: any) => c.text)
.join(""); .join("");
if (text) { if (text) {
setMessages((prev) => [...prev, { role: "assistant", content: text }]); setMessages((prev) => [...prev, { role: "assistant", content: text, timestamp: Date.now() }]);
setThreads((prev) => setThreads((prev) =>
prev.map((t) => prev.map((t) =>
t.sessionKey === activeThread t.sessionKey === activeThread
@@ -323,6 +409,7 @@ export function ChatPage() {
setStreaming(false); setStreaming(false);
setStreamText(""); setStreamText("");
} else if (payload.state === "aborted" || payload.state === "error") { } else if (payload.state === "aborted" || payload.state === "error") {
setThinking(false);
setStreaming(false); setStreaming(false);
if (streamText) { if (streamText) {
setMessages((prev) => [...prev, { role: "assistant", content: streamText }]); setMessages((prev) => [...prev, { role: "assistant", content: streamText }]);
@@ -385,6 +472,10 @@ export function ChatPage() {
setMessages([]); setMessages([]);
}; };
const handleRenameThread = (key: string, name: string) => {
setThreads((prev) => prev.map((t) => (t.sessionKey === key ? { ...t, name } : t)));
};
const handleDeleteThread = (key: string) => { const handleDeleteThread = (key: string) => {
setThreads((prev) => prev.filter((t) => t.sessionKey !== key)); setThreads((prev) => prev.filter((t) => t.sessionKey !== key));
if (activeThread === key) { if (activeThread === key) {
@@ -397,7 +488,8 @@ export function ChatPage() {
const handleSend = async (text: string) => { const handleSend = async (text: string) => {
if (!activeThread) return; if (!activeThread) return;
setMessages((prev) => [...prev, { role: "user", content: text }]); setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
setThinking(true);
setThreads((prev) => setThreads((prev) =>
prev.map((t) => prev.map((t) =>
t.sessionKey === activeThread t.sessionKey === activeThread
@@ -410,6 +502,7 @@ export function ChatPage() {
await client.chatSend(activeThread, text); await client.chatSend(activeThread, text);
} catch (e) { } catch (e) {
console.error("Failed to send:", e); console.error("Failed to send:", e);
setThinking(false);
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ role: "system", content: "Failed to send message. Please try again." }, { role: "system", content: "Failed to send message. Please try again." },
@@ -494,6 +587,7 @@ export function ChatPage() {
handleCreateThread(); handleCreateThread();
setShowThreads(false); setShowThreads(false);
}} }}
onRename={handleRenameThread}
onDelete={handleDeleteThread} onDelete={handleDeleteThread}
onClose={() => setShowThreads(false)} onClose={() => setShowThreads(false)}
/> />
@@ -504,6 +598,7 @@ export function ChatPage() {
messages={messages} messages={messages}
loading={loading} loading={loading}
streaming={streaming} streaming={streaming}
thinking={thinking}
streamText={streamText} streamText={streamText}
onSend={handleSend} onSend={handleSend}
onAbort={handleAbort} onAbort={handleAbort}