feat: progress notes UI, search/filter, chat backend relay (HQ-21)
- Add progress note input to TaskDetailPanel (textarea + Cmd+Enter submit) - Add addProgressNote API function - Add search bar and priority filter to Queue page - Include chat backend: WebSocket relay (gateway-relay.ts), chat routes (chat.ts) - Chat frontend updated to connect via backend relay (/api/chat/ws)
This commit is contained in:
@@ -3,6 +3,7 @@ import { cors } from "@elysiajs/cors";
|
||||
import { taskRoutes } from "./routes/tasks";
|
||||
import { adminRoutes } from "./routes/admin";
|
||||
import { projectRoutes } from "./routes/projects";
|
||||
import { chatRoutes } from "./routes/chat";
|
||||
import { auth } from "./lib/auth";
|
||||
import { db } from "./db";
|
||||
import { tasks, users } from "./db/schema";
|
||||
@@ -116,6 +117,7 @@ const app = new Elysia()
|
||||
.use(taskRoutes)
|
||||
.use(projectRoutes)
|
||||
.use(adminRoutes)
|
||||
.use(chatRoutes)
|
||||
|
||||
// Current user info (role, etc.)
|
||||
.get("/api/me", async ({ request }) => {
|
||||
|
||||
244
backend/src/lib/gateway-relay.ts
Normal file
244
backend/src/lib/gateway-relay.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Gateway WebSocket Relay
|
||||
*
|
||||
* Maintains a single persistent WebSocket connection to the Clawdbot gateway.
|
||||
* Dashboard clients connect through the backend (authenticated via BetterAuth),
|
||||
* and messages are relayed bidirectionally.
|
||||
*
|
||||
* Architecture:
|
||||
* Browser ←WSS→ Dashboard Backend ←WSS→ Clawdbot Gateway
|
||||
* (BetterAuth) (relay) (token auth)
|
||||
*/
|
||||
|
||||
const GATEWAY_URL = process.env.GATEWAY_WS_URL || "wss://hammer.donovankelly.xyz";
|
||||
const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || "";
|
||||
|
||||
type GatewayState = "disconnected" | "connecting" | "connected";
|
||||
type MessageHandler = (msg: any) => void;
|
||||
|
||||
let reqCounter = 0;
|
||||
function nextReqId() {
|
||||
return `relay-${++reqCounter}`;
|
||||
}
|
||||
|
||||
class GatewayConnection {
|
||||
private ws: WebSocket | null = null;
|
||||
private state: GatewayState = "disconnected";
|
||||
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void; timer: ReturnType<typeof setTimeout> }>();
|
||||
private eventListeners = new Set<MessageHandler>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
|
||||
constructor() {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.state === "connecting") return;
|
||||
this.state = "connecting";
|
||||
|
||||
if (!GATEWAY_TOKEN) {
|
||||
console.warn("[gateway-relay] No GATEWAY_WS_TOKEN set, chat relay disabled");
|
||||
this.state = "disconnected";
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[gateway-relay] Connecting to ${GATEWAY_URL}...`);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(GATEWAY_URL);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Failed to create WebSocket:", e);
|
||||
this.state = "disconnected";
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.addEventListener("open", () => {
|
||||
console.log("[gateway-relay] WebSocket open, sending handshake...");
|
||||
const connectId = nextReqId();
|
||||
this.sendRaw({
|
||||
type: "req",
|
||||
id: connectId,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: "dashboard-relay",
|
||||
displayName: "Hammer Dashboard Relay",
|
||||
version: "1.0.0",
|
||||
platform: "server",
|
||||
mode: "webchat",
|
||||
instanceId: `relay-${process.pid}-${Date.now()}`,
|
||||
},
|
||||
auth: {
|
||||
token: GATEWAY_TOKEN,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for handshake response
|
||||
this.pendingRequests.set(connectId, {
|
||||
resolve: () => {
|
||||
console.log("[gateway-relay] Connected to gateway");
|
||||
this.state = "connected";
|
||||
},
|
||||
reject: (err) => {
|
||||
console.error("[gateway-relay] Handshake failed:", err);
|
||||
this.state = "disconnected";
|
||||
this.ws?.close();
|
||||
},
|
||||
timer: setTimeout(() => {
|
||||
if (this.pendingRequests.has(connectId)) {
|
||||
this.pendingRequests.delete(connectId);
|
||||
console.error("[gateway-relay] Handshake timeout");
|
||||
this.state = "disconnected";
|
||||
this.ws?.close();
|
||||
}
|
||||
}, 15000),
|
||||
});
|
||||
});
|
||||
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(event.data));
|
||||
this.handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Failed to parse message:", e);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
console.log("[gateway-relay] Disconnected");
|
||||
this.state = "disconnected";
|
||||
this.ws = null;
|
||||
// Reject all pending requests
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("Connection closed"));
|
||||
this.pendingRequests.delete(id);
|
||||
}
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("error", (e) => {
|
||||
console.error("[gateway-relay] WebSocket error");
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (this.shouldReconnect && this.state === "disconnected") {
|
||||
this.connect();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private sendRaw(msg: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(msg: any) {
|
||||
if (msg.type === "res") {
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRequests.delete(msg.id);
|
||||
if (msg.ok !== false) {
|
||||
pending.resolve(msg.payload ?? msg.result ?? {});
|
||||
} else {
|
||||
pending.reject(new Error(msg.error?.message || "Request failed"));
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "event") {
|
||||
// Forward events to all listeners
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(msg);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Event listener error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.state === "connected";
|
||||
}
|
||||
|
||||
async request(method: string, params?: any): Promise<any> {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error("Gateway not connected");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextReqId();
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error("Request timeout"));
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
this.pendingRequests.set(id, { resolve, reject, timer });
|
||||
this.sendRaw({ type: "req", id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
onEvent(handler: MessageHandler): () => void {
|
||||
this.eventListeners.add(handler);
|
||||
return () => this.eventListeners.delete(handler);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.ws = null;
|
||||
this.state = "disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton gateway connection
|
||||
export const gateway = new GatewayConnection();
|
||||
|
||||
/**
|
||||
* Send a chat message to the gateway
|
||||
*/
|
||||
export async function chatSend(sessionKey: string, message: string): Promise<any> {
|
||||
return gateway.request("chat.send", {
|
||||
sessionKey,
|
||||
message,
|
||||
idempotencyKey: `dash-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat history from the gateway
|
||||
*/
|
||||
export async function chatHistory(sessionKey: string, limit = 50): Promise<any> {
|
||||
return gateway.request("chat.history", { sessionKey, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress chat response
|
||||
*/
|
||||
export async function chatAbort(sessionKey: string): Promise<any> {
|
||||
return gateway.request("chat.abort", { sessionKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions from the gateway
|
||||
*/
|
||||
export async function sessionsList(limit = 50): Promise<any> {
|
||||
return gateway.request("sessions.list", { limit });
|
||||
}
|
||||
269
backend/src/routes/chat.ts
Normal file
269
backend/src/routes/chat.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Chat routes - WebSocket relay + REST fallback for dashboard chat
|
||||
*
|
||||
* WebSocket: /api/chat/ws - Real-time bidirectional relay to gateway
|
||||
* REST: /api/chat/send, /api/chat/history, /api/chat/sessions - Fallback endpoints
|
||||
*/
|
||||
import { Elysia } from "elysia";
|
||||
import { auth } from "../lib/auth";
|
||||
import { gateway, chatSend, chatHistory, chatAbort, sessionsList } from "../lib/gateway-relay";
|
||||
|
||||
// Track active WebSocket client connections
|
||||
const activeClients = new Map<string, {
|
||||
ws: any;
|
||||
userId: string;
|
||||
sessionKeys: Set<string>;
|
||||
}>();
|
||||
|
||||
export const chatRoutes = new Elysia()
|
||||
// WebSocket endpoint for real-time chat relay
|
||||
.ws("/api/chat/ws", {
|
||||
open(ws) {
|
||||
const clientId = `client-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
(ws.data as any).__clientId = clientId;
|
||||
(ws.data as any).__authenticated = false;
|
||||
console.log(`[chat-ws] Client connected: ${clientId}`);
|
||||
},
|
||||
|
||||
async message(ws, rawMsg) {
|
||||
const clientId = (ws.data as any).__clientId || "unknown";
|
||||
|
||||
let msg: any;
|
||||
try {
|
||||
msg = typeof rawMsg === "string" ? JSON.parse(rawMsg) : rawMsg;
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: "error", error: "Invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// First message must be auth
|
||||
if (!(ws.data as any).__authenticated) {
|
||||
if (msg.type !== "auth") {
|
||||
ws.send(JSON.stringify({ type: "error", error: "Must authenticate first" }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate session cookie or token
|
||||
const session = await validateAuth(msg);
|
||||
if (!session) {
|
||||
ws.send(JSON.stringify({ type: "error", error: "Authentication failed" }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
(ws.data as any).__authenticated = true;
|
||||
(ws.data as any).__userId = session.user.id;
|
||||
(ws.data as any).__userName = session.user.name || session.user.email;
|
||||
|
||||
// Register client
|
||||
activeClients.set(clientId, {
|
||||
ws,
|
||||
userId: session.user.id,
|
||||
sessionKeys: new Set(),
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: "auth_ok",
|
||||
user: { id: session.user.id, name: session.user.name },
|
||||
gatewayConnected: gateway.isConnected(),
|
||||
}));
|
||||
|
||||
console.log(`[chat-ws] Client authenticated: ${clientId} (${session.user.name || session.user.email})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle authenticated messages
|
||||
try {
|
||||
await handleClientMessage(clientId, ws, msg);
|
||||
} catch (e: any) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "error",
|
||||
id: msg.id,
|
||||
error: e.message || "Internal error",
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
close(ws) {
|
||||
const clientId = (ws.data as any).__clientId || "unknown";
|
||||
activeClients.delete(clientId);
|
||||
console.log(`[chat-ws] Client disconnected: ${clientId}`);
|
||||
},
|
||||
})
|
||||
|
||||
// REST: Send a chat message
|
||||
.post("/api/chat/send", async ({ request, body }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
const { sessionKey, message } = body as { sessionKey: string; message: string };
|
||||
if (!sessionKey || !message) {
|
||||
return new Response(JSON.stringify({ error: "sessionKey and message required" }), { status: 400 });
|
||||
}
|
||||
|
||||
if (!gateway.isConnected()) {
|
||||
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await chatSend(sessionKey, message);
|
||||
return { ok: true, result };
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
|
||||
}
|
||||
})
|
||||
|
||||
// REST: Get chat history
|
||||
.get("/api/chat/history/:sessionKey", async ({ request, params }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
if (!gateway.isConnected()) {
|
||||
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await chatHistory(params.sessionKey);
|
||||
return { ok: true, ...result };
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
|
||||
}
|
||||
})
|
||||
|
||||
// REST: List sessions
|
||||
.get("/api/chat/sessions", async ({ request }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
if (!gateway.isConnected()) {
|
||||
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sessionsList();
|
||||
return { ok: true, ...result };
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
|
||||
}
|
||||
})
|
||||
|
||||
// REST: Gateway connection status
|
||||
.get("/api/chat/status", async ({ request }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
return {
|
||||
gatewayConnected: gateway.isConnected(),
|
||||
activeClients: activeClients.size,
|
||||
};
|
||||
});
|
||||
|
||||
// Validate auth from WebSocket auth message
|
||||
async function validateAuth(msg: any): Promise<any> {
|
||||
// Support cookie-based auth (pass cookie string)
|
||||
if (msg.cookie) {
|
||||
try {
|
||||
// Create a fake request with the cookie header for BetterAuth
|
||||
const headers = new Headers();
|
||||
headers.set("cookie", msg.cookie);
|
||||
const session = await auth.api.getSession({ headers });
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Support bearer token auth
|
||||
if (msg.token) {
|
||||
try {
|
||||
const headers = new Headers();
|
||||
headers.set("authorization", `Bearer ${msg.token}`);
|
||||
const session = await auth.api.getSession({ headers });
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle messages from authenticated WebSocket clients
|
||||
async function handleClientMessage(clientId: string, ws: any, msg: any) {
|
||||
const client = activeClients.get(clientId);
|
||||
if (!client) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case "chat.send": {
|
||||
const { sessionKey, message } = msg;
|
||||
if (!sessionKey || !message) {
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey and message required" }));
|
||||
return;
|
||||
}
|
||||
client.sessionKeys.add(sessionKey);
|
||||
const result = await chatSend(sessionKey, message);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "chat.history": {
|
||||
const { sessionKey, limit } = msg;
|
||||
if (!sessionKey) {
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey required" }));
|
||||
return;
|
||||
}
|
||||
client.sessionKeys.add(sessionKey);
|
||||
const result = await chatHistory(sessionKey, limit);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "chat.abort": {
|
||||
const { sessionKey } = msg;
|
||||
if (!sessionKey) {
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey required" }));
|
||||
return;
|
||||
}
|
||||
const result = await chatAbort(sessionKey);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "sessions.list": {
|
||||
const result = await sessionsList(msg.limit);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: `Unknown message type: ${msg.type}` }));
|
||||
}
|
||||
}
|
||||
|
||||
// Forward gateway events to relevant WebSocket clients
|
||||
gateway.onEvent((msg: any) => {
|
||||
if (msg.type !== "event") return;
|
||||
|
||||
const payload = msg.payload || {};
|
||||
const sessionKey = payload.sessionKey;
|
||||
|
||||
for (const [, client] of activeClients) {
|
||||
// Forward to clients subscribed to this session key, or broadcast if no key
|
||||
if (!sessionKey || client.sessionKeys.has(sessionKey)) {
|
||||
try {
|
||||
client.ws.send(JSON.stringify(msg));
|
||||
} catch {
|
||||
// Client disconnected, will be cleaned up
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -25,6 +25,8 @@ 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: ${GATEWAY_WS_URL:-wss://hammer.donovankelly.xyz}
|
||||
GATEWAY_WS_TOKEN: ${GATEWAY_WS_TOKEN}
|
||||
PORT: "3100"
|
||||
depends_on:
|
||||
- db
|
||||
@@ -34,9 +36,6 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_WS_URL: ${VITE_WS_URL:-wss://hammer.donovankelly.xyz}
|
||||
VITE_WS_TOKEN: ${VITE_WS_TOKEN}
|
||||
ports:
|
||||
- "80"
|
||||
depends_on:
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
FROM oven/bun:1 AS build
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_WS_URL=""
|
||||
ARG VITE_WS_TOKEN=""
|
||||
ENV VITE_WS_URL=$VITE_WS_URL
|
||||
ENV VITE_WS_TOKEN=$VITE_WS_TOKEN
|
||||
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
|
||||
|
||||
@@ -8,12 +8,19 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
# Proxy API requests to backend (including WebSocket for chat)
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3100;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
location /health {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Task, TaskStatus, TaskPriority, TaskSource, Project } from "../lib/types";
|
||||
import { updateTask, fetchProjects } from "../lib/api";
|
||||
import { updateTask, fetchProjects, addProgressNote } from "../lib/api";
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
critical: "bg-red-500 text-white",
|
||||
@@ -244,6 +244,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
const actions = statusActions[task.status] || [];
|
||||
const isActive = task.status === "active";
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [noteText, setNoteText] = useState("");
|
||||
const [addingNote, setAddingNote] = useState(false);
|
||||
|
||||
// Draft state for editable fields
|
||||
const [draftTitle, setDraftTitle] = useState(task.title);
|
||||
@@ -514,6 +516,55 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
<span className="text-gray-300 ml-1">({task.progressNotes.length})</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{/* Add note input */}
|
||||
{hasToken && (
|
||||
<div className="mb-4">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={noteText}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
placeholder="Add a progress note..."
|
||||
rows={2}
|
||||
className="flex-1 text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300 resize-y min-h-[40px] max-h-32"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (noteText.trim()) {
|
||||
setAddingNote(true);
|
||||
addProgressNote(task.id, noteText.trim())
|
||||
.then(() => {
|
||||
setNoteText("");
|
||||
onTaskUpdated();
|
||||
})
|
||||
.catch((err) => console.error("Failed to add note:", err))
|
||||
.finally(() => setAddingNote(false));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!noteText.trim()) return;
|
||||
setAddingNote(true);
|
||||
addProgressNote(task.id, noteText.trim())
|
||||
.then(() => {
|
||||
setNoteText("");
|
||||
onTaskUpdated();
|
||||
})
|
||||
.catch((err) => console.error("Failed to add note:", err))
|
||||
.finally(() => setAddingNote(false));
|
||||
}}
|
||||
disabled={!noteText.trim() || addingNote}
|
||||
className="self-end px-3 py-2 bg-amber-500 text-white text-sm rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
>
|
||||
{addingNote ? "..." : "Add"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-1">⌘+Enter to submit</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!task.progressNotes || task.progressNotes.length === 0 ? (
|
||||
<div className="text-sm text-gray-400 italic py-4 text-center border-2 border-dashed border-gray-100 rounded-lg">
|
||||
No progress notes yet
|
||||
|
||||
@@ -115,6 +115,18 @@ export async function deleteProject(id: string): Promise<void> {
|
||||
if (!res.ok) throw new Error("Failed to delete project");
|
||||
}
|
||||
|
||||
// Progress Notes
|
||||
export async function addProgressNote(taskId: string, note: string): Promise<Task> {
|
||||
const res = await fetch(`${BASE}/${taskId}/notes`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to add progress note");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Admin API
|
||||
export async function fetchUsers(): Promise<any[]> {
|
||||
const res = await fetch("/api/admin/users", { credentials: "include" });
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
// Gateway WebSocket client for Hammer Dashboard chat
|
||||
/**
|
||||
* Chat WebSocket client for Hammer Dashboard
|
||||
*
|
||||
* Connects to the dashboard backend's WebSocket relay (which proxies to the Clawdbot gateway).
|
||||
* Authentication is handled via BetterAuth session cookie.
|
||||
*/
|
||||
|
||||
type MessageHandler = (msg: any) => void;
|
||||
type StateHandler = (connected: boolean) => void;
|
||||
type StateHandler = (state: "connecting" | "connected" | "disconnected") => void;
|
||||
|
||||
let reqCounter = 0;
|
||||
function nextId() {
|
||||
return `r${++reqCounter}`;
|
||||
}
|
||||
|
||||
export class GatewayClient {
|
||||
export class ChatClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private token: string;
|
||||
private connected = false;
|
||||
private state: "connecting" | "connected" | "disconnected" = "disconnected";
|
||||
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
||||
private eventHandlers = new Map<string, Set<MessageHandler>>();
|
||||
private stateHandlers = new Set<StateHandler>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
|
||||
constructor(url: string, token: string) {
|
||||
this.url = url;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.shouldReconnect = true;
|
||||
this._connect();
|
||||
@@ -34,43 +32,19 @@ export class GatewayClient {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
// Build WebSocket URL from current page origin
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
// Backend is at the same origin via nginx proxy on Dokploy
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/chat/ws`;
|
||||
|
||||
this.setState("connecting");
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
// Send connect handshake
|
||||
const connectId = nextId();
|
||||
// Send auth message with session cookie
|
||||
this._send({
|
||||
type: "req",
|
||||
id: connectId,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: "webchat",
|
||||
displayName: "Hammer Dashboard",
|
||||
version: "1.0.0",
|
||||
platform: "web",
|
||||
mode: "webchat",
|
||||
instanceId: `dash-${Date.now()}`,
|
||||
},
|
||||
auth: {
|
||||
token: this.token,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for hello-ok
|
||||
this.pendingRequests.set(connectId, {
|
||||
resolve: () => {
|
||||
this.connected = true;
|
||||
this.stateHandlers.forEach((h) => h(true));
|
||||
},
|
||||
reject: (err) => {
|
||||
console.error("Gateway connect failed:", err);
|
||||
this.connected = false;
|
||||
this.stateHandlers.forEach((h) => h(false));
|
||||
},
|
||||
type: "auth",
|
||||
cookie: document.cookie,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -79,20 +53,19 @@ export class GatewayClient {
|
||||
const msg = JSON.parse(event.data);
|
||||
this._handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse gateway message:", e);
|
||||
console.error("Failed to parse message:", e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.connected = false;
|
||||
this.stateHandlers.forEach((h) => h(false));
|
||||
this.setState("disconnected");
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnectTimer = setTimeout(() => this._connect(), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose will handle reconnect
|
||||
// onclose handles reconnect
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,43 +76,46 @@ export class GatewayClient {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.ws = null;
|
||||
this.connected = false;
|
||||
this.setState("disconnected");
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.connected;
|
||||
return this.state === "connected";
|
||||
}
|
||||
|
||||
onStateChange(handler: StateHandler) {
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
onStateChange(handler: StateHandler): () => void {
|
||||
this.stateHandlers.add(handler);
|
||||
return () => this.stateHandlers.delete(handler);
|
||||
return () => { this.stateHandlers.delete(handler); };
|
||||
}
|
||||
|
||||
on(event: string, handler: MessageHandler) {
|
||||
on(event: string, handler: MessageHandler): () => void {
|
||||
if (!this.eventHandlers.has(event)) {
|
||||
this.eventHandlers.set(event, new Set());
|
||||
}
|
||||
this.eventHandlers.get(event)!.add(handler);
|
||||
return () => this.eventHandlers.get(event)?.delete(handler);
|
||||
return () => { this.eventHandlers.get(event)?.delete(handler); };
|
||||
}
|
||||
|
||||
async request(method: string, params?: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ws || !this.connected) {
|
||||
if (!this.ws || this.state !== "connected") {
|
||||
reject(new Error("Not connected"));
|
||||
return;
|
||||
}
|
||||
const id = nextId();
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
this._send({ type: "req", id, method, params });
|
||||
this._send({ type: method, id, ...params });
|
||||
|
||||
// Timeout after 60s
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error("Request timeout"));
|
||||
}
|
||||
}, 60000);
|
||||
}, 120000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,7 +128,6 @@ export class GatewayClient {
|
||||
return this.request("chat.send", {
|
||||
sessionKey,
|
||||
message,
|
||||
idempotencyKey: `dash-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,6 +139,11 @@ export class GatewayClient {
|
||||
return this.request("sessions.list", { limit });
|
||||
}
|
||||
|
||||
private setState(state: "connecting" | "connected" | "disconnected") {
|
||||
this.state = state;
|
||||
this.stateHandlers.forEach((h) => h(state));
|
||||
}
|
||||
|
||||
private _send(msg: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
@@ -171,6 +151,20 @@ export class GatewayClient {
|
||||
}
|
||||
|
||||
private _handleMessage(msg: any) {
|
||||
// Auth response
|
||||
if (msg.type === "auth_ok") {
|
||||
this.setState("connected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "error" && this.state !== "connected") {
|
||||
console.error("Auth failed:", msg.error);
|
||||
this.shouldReconnect = false;
|
||||
this.ws?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Request response
|
||||
if (msg.type === "res") {
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (pending) {
|
||||
@@ -178,15 +172,28 @@ export class GatewayClient {
|
||||
if (msg.ok) {
|
||||
pending.resolve(msg.payload);
|
||||
} else {
|
||||
pending.reject(new Error(msg.error?.message || "Request failed"));
|
||||
pending.reject(new Error(msg.error || "Request failed"));
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "event") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Error for a specific request
|
||||
if (msg.type === "error" && msg.id) {
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(msg.id);
|
||||
pending.reject(new Error(msg.error || "Request failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Gateway events (forwarded from backend)
|
||||
if (msg.type === "event") {
|
||||
const handlers = this.eventHandlers.get(msg.event);
|
||||
if (handlers) {
|
||||
handlers.forEach((h) => h(msg.payload));
|
||||
}
|
||||
// Also fire wildcard handlers
|
||||
const wildcardHandlers = this.eventHandlers.get("*");
|
||||
if (wildcardHandlers) {
|
||||
wildcardHandlers.forEach((h) => h({ event: msg.event, ...msg.payload }));
|
||||
@@ -194,3 +201,13 @@ export class GatewayClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let _client: ChatClient | null = null;
|
||||
|
||||
export function getChatClient(): ChatClient {
|
||||
if (!_client) {
|
||||
_client = new ChatClient();
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { GatewayClient } from "../lib/gateway";
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || "wss://hammer.donovankelly.xyz";
|
||||
const WS_TOKEN = import.meta.env.VITE_WS_TOKEN || import.meta.env.VITE_GATEWAY_TOKEN || "";
|
||||
import { getChatClient, type ChatClient } from "../lib/gateway";
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
@@ -22,12 +19,14 @@ function ThreadList({
|
||||
activeThread,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
threads: ChatThread[];
|
||||
activeThread: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
onCreate: () => void;
|
||||
onDelete?: (key: string) => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -61,16 +60,16 @@ function ThreadList({
|
||||
</div>
|
||||
) : (
|
||||
threads.map((thread) => (
|
||||
<button
|
||||
<div
|
||||
key={thread.sessionKey}
|
||||
onClick={() => onSelect(thread.sessionKey)}
|
||||
className={`w-full text-left px-3 py-3 border-b border-gray-50 transition ${
|
||||
className={`group relative w-full text-left px-3 py-3 border-b border-gray-50 transition cursor-pointer ${
|
||||
activeThread === thread.sessionKey
|
||||
? "bg-amber-50 border-l-2 border-l-amber-500"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => onSelect(thread.sessionKey)}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-800 truncate">
|
||||
<div className="text-sm font-medium text-gray-800 truncate pr-6">
|
||||
{thread.name}
|
||||
</div>
|
||||
{thread.lastMessage && (
|
||||
@@ -78,7 +77,21 @@ function ThreadList({
|
||||
{thread.lastMessage}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(thread.sessionKey);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-400 transition p-1"
|
||||
aria-label="Delete thread"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -127,7 +140,7 @@ function ChatArea({
|
||||
streamText,
|
||||
onSend,
|
||||
onAbort,
|
||||
connected,
|
||||
connectionState,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
loading: boolean;
|
||||
@@ -135,7 +148,7 @@ function ChatArea({
|
||||
streamText: string;
|
||||
onSend: (msg: string) => void;
|
||||
onAbort: () => void;
|
||||
connected: boolean;
|
||||
connectionState: "connecting" | "connected" | "disconnected";
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -147,7 +160,7 @@ function ChatArea({
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim();
|
||||
if (!text || !connected) return;
|
||||
if (!text || connectionState !== "connected") return;
|
||||
onSend(text);
|
||||
setInput("");
|
||||
inputRef.current?.focus();
|
||||
@@ -160,6 +173,8 @@ function ChatArea({
|
||||
}
|
||||
};
|
||||
|
||||
const connected = connectionState === "connected";
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
{/* Messages */}
|
||||
@@ -186,12 +201,18 @@ function ChatArea({
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-gray-200 bg-white px-4 py-3">
|
||||
{!connected && (
|
||||
{connectionState === "disconnected" && (
|
||||
<div className="text-xs text-red-500 mb-2 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full" />
|
||||
Disconnected — reconnecting...
|
||||
</div>
|
||||
)}
|
||||
{connectionState === "connecting" && (
|
||||
<div className="text-xs text-amber-500 mb-2 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse" />
|
||||
Connecting...
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 items-end">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
@@ -227,8 +248,8 @@ function ChatArea({
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
const [gateway] = useState(() => new GatewayClient(WS_URL, WS_TOKEN));
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [client] = useState<ChatClient>(() => getChatClient());
|
||||
const [connectionState, setConnectionState] = useState<"connecting" | "connected" | "disconnected">("disconnected");
|
||||
const [threads, setThreads] = useState<ChatThread[]>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("hammer-chat-threads") || "[]");
|
||||
@@ -241,33 +262,30 @@ export function ChatPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [streamText, setStreamText] = useState("");
|
||||
const [showThreads, setShowThreads] = useState(false);
|
||||
|
||||
// Persist threads to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("hammer-chat-threads", JSON.stringify(threads));
|
||||
}, [threads]);
|
||||
|
||||
// Connect to gateway
|
||||
// Connect client
|
||||
useEffect(() => {
|
||||
if (!WS_TOKEN) return;
|
||||
|
||||
gateway.connect();
|
||||
const unsub = gateway.onStateChange(setConnected);
|
||||
client.connect();
|
||||
const unsub = client.onStateChange(setConnectionState);
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
gateway.disconnect();
|
||||
};
|
||||
}, [gateway]);
|
||||
}, [client]);
|
||||
|
||||
// Listen for chat events (streaming responses)
|
||||
useEffect(() => {
|
||||
const unsub: () => void = gateway.on("chat", (payload: any) => {
|
||||
const unsub = client.on("chat", (payload: any) => {
|
||||
if (payload.sessionKey !== activeThread) return;
|
||||
|
||||
if (payload.state === "delta" && payload.message?.content) {
|
||||
setStreaming(true);
|
||||
// Accumulate delta text
|
||||
const textParts = payload.message.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
@@ -276,7 +294,6 @@ export function ChatPage() {
|
||||
setStreamText((prev) => prev + textParts);
|
||||
}
|
||||
} else if (payload.state === "final") {
|
||||
// Final message — add to messages
|
||||
if (payload.message?.content) {
|
||||
const text = payload.message.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
@@ -284,7 +301,6 @@ export function ChatPage() {
|
||||
.join("");
|
||||
if (text) {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: text }]);
|
||||
// Update thread last message
|
||||
setThreads((prev) =>
|
||||
prev.map((t) =>
|
||||
t.sessionKey === activeThread
|
||||
@@ -299,14 +315,13 @@ export function ChatPage() {
|
||||
} else if (payload.state === "aborted" || payload.state === "error") {
|
||||
setStreaming(false);
|
||||
if (streamText) {
|
||||
// Save partial response
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: streamText }]);
|
||||
}
|
||||
setStreamText("");
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [gateway, activeThread, streamText]);
|
||||
}, [client, activeThread, streamText]);
|
||||
|
||||
// Load messages when thread changes
|
||||
const loadMessages = useCallback(
|
||||
@@ -314,7 +329,7 @@ export function ChatPage() {
|
||||
setLoading(true);
|
||||
setMessages([]);
|
||||
try {
|
||||
const result = await gateway.chatHistory(sessionKey);
|
||||
const result = await client.chatHistory(sessionKey);
|
||||
if (result?.messages) {
|
||||
const msgs: ChatMessage[] = result.messages
|
||||
.filter((m: any) => m.role === "user" || m.role === "assistant")
|
||||
@@ -339,14 +354,14 @@ export function ChatPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[gateway]
|
||||
[client]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeThread && connected) {
|
||||
if (activeThread && connectionState === "connected") {
|
||||
loadMessages(activeThread);
|
||||
}
|
||||
}, [activeThread, connected, loadMessages]);
|
||||
}, [activeThread, connectionState, loadMessages]);
|
||||
|
||||
const handleCreateThread = () => {
|
||||
const id = `dash:chat:${Date.now()}`;
|
||||
@@ -360,10 +375,18 @@ export function ChatPage() {
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const handleDeleteThread = (key: string) => {
|
||||
setThreads((prev) => prev.filter((t) => t.sessionKey !== key));
|
||||
if (activeThread === key) {
|
||||
const remaining = threads.filter((t) => t.sessionKey !== key);
|
||||
setActiveThread(remaining.length > 0 ? remaining[0].sessionKey : null);
|
||||
setMessages([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (text: string) => {
|
||||
if (!activeThread) return;
|
||||
|
||||
// Add user message immediately
|
||||
setMessages((prev) => [...prev, { role: "user", content: text }]);
|
||||
setThreads((prev) =>
|
||||
prev.map((t) =>
|
||||
@@ -374,7 +397,7 @@ export function ChatPage() {
|
||||
);
|
||||
|
||||
try {
|
||||
await gateway.chatSend(activeThread, text);
|
||||
await client.chatSend(activeThread, text);
|
||||
} catch (e) {
|
||||
console.error("Failed to send:", e);
|
||||
setMessages((prev) => [
|
||||
@@ -387,7 +410,7 @@ export function ChatPage() {
|
||||
const handleAbort = async () => {
|
||||
if (!activeThread) return;
|
||||
try {
|
||||
await gateway.chatAbort(activeThread);
|
||||
await client.chatAbort(activeThread);
|
||||
} catch (e) {
|
||||
console.error("Failed to abort:", e);
|
||||
}
|
||||
@@ -402,22 +425,6 @@ export function ChatPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!WS_TOKEN) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center p-8">
|
||||
<span className="text-4xl block mb-4">🔒</span>
|
||||
<h2 className="text-lg font-semibold text-gray-600 mb-2">Chat not configured</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Gateway WebSocket token not set. Add VITE_WS_TOKEN to environment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [showThreads, setShowThreads] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-3.5rem)] md:h-screen flex flex-col">
|
||||
{/* Page Header */}
|
||||
@@ -437,10 +444,16 @@ export function ChatPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connectionState === "connected"
|
||||
? "bg-green-500"
|
||||
: connectionState === "connecting"
|
||||
? "bg-amber-500 animate-pulse"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
{connectionState === "connected" ? "Connected" : connectionState === "connecting" ? "Connecting..." : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,7 +461,7 @@ export function ChatPage() {
|
||||
|
||||
{/* Chat body */}
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{/* Thread list - overlay on mobile, sidebar on desktop */}
|
||||
{/* Thread list */}
|
||||
<div className={`
|
||||
absolute inset-0 z-20 sm:relative sm:inset-auto sm:z-auto
|
||||
${showThreads ? "block" : "hidden"} sm:block
|
||||
@@ -471,6 +484,7 @@ export function ChatPage() {
|
||||
handleCreateThread();
|
||||
setShowThreads(false);
|
||||
}}
|
||||
onDelete={handleDeleteThread}
|
||||
onClose={() => setShowThreads(false)}
|
||||
/>
|
||||
</div>
|
||||
@@ -483,7 +497,7 @@ export function ChatPage() {
|
||||
streamText={streamText}
|
||||
onSend={handleSend}
|
||||
onAbort={handleAbort}
|
||||
connected={connected}
|
||||
connectionState={connectionState}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 p-4 text-center">
|
||||
|
||||
@@ -13,18 +13,37 @@ export function QueuePage() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterPriority, setFilterPriority] = useState<string>("");
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
let filtered = tasks;
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(q) ||
|
||||
(t.description && t.description.toLowerCase().includes(q)) ||
|
||||
(t.taskNumber && `hq-${t.taskNumber}`.includes(q))
|
||||
);
|
||||
}
|
||||
if (filterPriority) {
|
||||
filtered = filtered.filter((t) => t.priority === filterPriority);
|
||||
}
|
||||
return filtered;
|
||||
}, [tasks, search, filterPriority]);
|
||||
|
||||
const selectedTaskData = useMemo(() => {
|
||||
if (!selectedTask) return null;
|
||||
return tasks.find((t) => t.id === selectedTask) || null;
|
||||
}, [tasks, selectedTask]);
|
||||
|
||||
const activeTasks = useMemo(() => tasks.filter((t) => t.status === "active"), [tasks]);
|
||||
const queuedTasks = useMemo(() => tasks.filter((t) => t.status === "queued"), [tasks]);
|
||||
const blockedTasks = useMemo(() => tasks.filter((t) => t.status === "blocked"), [tasks]);
|
||||
const activeTasks = useMemo(() => filteredTasks.filter((t) => t.status === "active"), [filteredTasks]);
|
||||
const queuedTasks = useMemo(() => filteredTasks.filter((t) => t.status === "queued"), [filteredTasks]);
|
||||
const blockedTasks = useMemo(() => filteredTasks.filter((t) => t.status === "blocked"), [filteredTasks]);
|
||||
const completedTasks = useMemo(
|
||||
() => tasks.filter((t) => t.status === "completed" || t.status === "cancelled"),
|
||||
[tasks]
|
||||
() => filteredTasks.filter((t) => t.status === "completed" || t.status === "cancelled"),
|
||||
[filteredTasks]
|
||||
);
|
||||
|
||||
const handleStatusChange = async (id: string, status: TaskStatus) => {
|
||||
@@ -66,17 +85,54 @@ export function QueuePage() {
|
||||
<div className="min-h-screen">
|
||||
{/* Page Header */}
|
||||
<header className="bg-white border-b border-gray-200 sticky top-14 md:top-0 z-30">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-bold text-gray-900">Task Queue</h1>
|
||||
<p className="text-xs sm:text-sm text-gray-400">Manage what Hammer is working on</p>
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-bold text-gray-900">Task Queue</h1>
|
||||
<p className="text-xs sm:text-sm text-gray-400">Manage what Hammer is working on</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="text-sm bg-amber-500 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1 relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search tasks..."
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 p-1"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={filterPriority}
|
||||
onChange={(e) => setFilterPriority(e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white"
|
||||
>
|
||||
<option value="">All priorities</option>
|
||||
<option value="critical">🔴 Critical</option>
|
||||
<option value="high">🟠 High</option>
|
||||
<option value="medium">🔵 Medium</option>
|
||||
<option value="low">⚪ Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="text-sm bg-amber-500 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user