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" }))
|
.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" };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
<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}
|
{thread.name}
|
||||||
</div>
|
</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,14 +161,18 @@ 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 className="flex flex-col">
|
||||||
<div
|
<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
|
isUser
|
||||||
? "bg-blue-500 text-white rounded-br-md"
|
? "bg-blue-500 text-white rounded-br-md"
|
||||||
: "bg-gray-100 text-gray-800 rounded-bl-md"
|
: "bg-gray-100 text-gray-800 rounded-bl-md"
|
||||||
@@ -139,6 +188,29 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -147,6 +219,7 @@ 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user