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:
2026-01-29 06:05:03 +00:00
parent b0559cdbc8
commit 5cfde2f2e7
11 changed files with 809 additions and 143 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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" });

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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>