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:
@@ -140,7 +140,7 @@ const app = new Elysia()
|
||||
|
||||
.get("/health", () => ({ status: "ok", service: "hammer-queue" }))
|
||||
.onError(({ error, set }) => {
|
||||
const msg = error?.message || String(error);
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
* (BetterAuth) (relay) (token auth)
|
||||
*/
|
||||
|
||||
const GATEWAY_URL = process.env.GATEWAY_WS_URL || process.env.VITE_WS_URL || "wss://hammer.donovankelly.xyz";
|
||||
const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || process.env.VITE_WS_TOKEN || "";
|
||||
const GATEWAY_URL = process.env.GATEWAY_WS_URL || "wss://ws.hammer.donovankelly.xyz";
|
||||
const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || "";
|
||||
|
||||
type GatewayState = "disconnected" | "connecting" | "connected";
|
||||
type MessageHandler = (msg: any) => void;
|
||||
|
||||
@@ -19,7 +19,7 @@ async function requireAdmin(request: Request, headers: Record<string, string | u
|
||||
|
||||
export const adminRoutes = new Elysia({ prefix: "/api/admin" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = error?.message || String(error);
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
|
||||
@@ -21,7 +21,7 @@ async function requireSessionOrBearer(
|
||||
|
||||
export const projectRoutes = new Elysia({ prefix: "/api/projects" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = error?.message || String(error);
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
|
||||
@@ -104,7 +104,7 @@ async function resolveTask(idOrNumber: string) {
|
||||
|
||||
export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = error?.message || String(error);
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
COOKIE_DOMAIN: .donovankelly.xyz
|
||||
CLAWDBOT_HOOK_URL: ${CLAWDBOT_HOOK_URL:-https://hammer.donovankelly.xyz/hooks/agent}
|
||||
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}
|
||||
PORT: "3100"
|
||||
depends_on:
|
||||
|
||||
@@ -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">
|
||||
{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,14 +161,18 @@ 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="flex flex-col">
|
||||
<div
|
||||
className={`max-w-[75%] rounded-2xl px-4 py-2.5 ${
|
||||
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"
|
||||
@@ -139,6 +188,29 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -147,6 +219,7 @@ 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}
|
||||
|
||||
Reference in New Issue
Block a user