diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index b1acbf3..5d44fd0 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -5,6 +5,37 @@ import { eq, asc, desc, sql, inArray } from "drizzle-orm"; import { auth } from "../lib/auth"; const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token"; +const CLAWDBOT_HOOK_URL = process.env.CLAWDBOT_HOOK_URL || "http://127.0.0.1:18789/hooks/agent"; +const CLAWDBOT_HOOK_TOKEN = process.env.CLAWDBOT_HOOK_TOKEN || ""; + +// Fire webhook to Clawdbot when a task is activated +async function notifyTaskActivated(task: { id: string; title: string; description: string | null; source: string; priority: string }) { + if (!CLAWDBOT_HOOK_TOKEN) { + console.warn("CLAWDBOT_HOOK_TOKEN not set โ€” skipping webhook"); + return; + } + try { + const message = `๐Ÿ”จ Task activated in Hammer Queue:\n\nTitle: ${task.title}\nPriority: ${task.priority}\nSource: ${task.source}\nID: ${task.id}\n${task.description ? `\nDescription: ${task.description}` : ""}\n\nStart working on this task. Post progress notes to the queue API as you work:\ncurl -s -H "Authorization: Bearer $HAMMER_QUEUE_API_KEY" -H "Content-Type: application/json" -X POST "https://queue.donovankelly.xyz/api/tasks/${task.id}/notes" -d '{"note":"your update here"}'`; + + await fetch(CLAWDBOT_HOOK_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${CLAWDBOT_HOOK_TOKEN}`, + }, + body: JSON.stringify({ + message, + name: "HammerQueue", + sessionKey: `hook:queue:${task.id}`, + deliver: true, + channel: "telegram", + }), + }); + console.log(`Webhook fired for task ${task.id}: ${task.title}`); + } catch (err) { + console.error("Failed to fire webhook:", err); + } +} // Status sort order: active first, then queued, blocked, completed, cancelled const statusOrder = sql`CASE @@ -62,11 +93,11 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) return allTasks; }) - // POST create task - requires auth + // POST create task - requires session or bearer auth .post( "/", - async ({ body, headers }) => { - requireBearerAuth(headers); + async ({ body, request, headers }) => { + await requireSessionOrBearer(request, headers); // Get max position for queued tasks const maxPos = await db .select({ max: sql`COALESCE(MAX(${tasks.position}), 0)` }) @@ -120,11 +151,11 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) } ) - // PATCH update task - requires auth + // PATCH update task - requires session or bearer auth .patch( "/:id", - async ({ params, body, headers }) => { - requireBearerAuth(headers); + async ({ params, body, request, headers }) => { + await requireSessionOrBearer(request, headers); const updates: Record = { updatedAt: new Date() }; if (body.title !== undefined) updates.title = body.title; if (body.description !== undefined) updates.description = body.description; @@ -151,6 +182,12 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) .where(eq(tasks.id, params.id)) .returning(); if (!updated.length) throw new Error("Task not found"); + + // Fire webhook if task was just activated + if (body.status === "active") { + notifyTaskActivated(updated[0]); + } + return updated[0]; }, { @@ -169,8 +206,8 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) // POST add progress note - requires auth .post( "/:id/notes", - async ({ params, body, headers }) => { - requireBearerAuth(headers); + async ({ params, body, request, headers }) => { + await requireSessionOrBearer(request, headers); const existing = await db .select() .from(tasks) @@ -200,8 +237,8 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) // PATCH reorder tasks - requires auth .patch( "/reorder", - async ({ body, headers }) => { - requireBearerAuth(headers); + async ({ body, request, headers }) => { + await requireSessionOrBearer(request, headers); // body.ids is an ordered array of task IDs const updates = body.ids.map((id: string, index: number) => db @@ -220,8 +257,8 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) // DELETE task - requires auth .delete( "/:id", - async ({ params, headers }) => { - requireBearerAuth(headers); + async ({ params, request, headers }) => { + await requireSessionOrBearer(request, headers); const deleted = await db .delete(tasks) .where(eq(tasks.id, params.id)) diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index 01a0bef..36276ee 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -21,6 +21,8 @@ services: BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} BETTER_AUTH_URL: https://queue.donovankelly.xyz COOKIE_DOMAIN: .donovankelly.xyz + CLAWDBOT_HOOK_URL: ${CLAWDBOT_HOOK_URL} + CLAWDBOT_HOOK_TOKEN: ${CLAWDBOT_HOOK_TOKEN} PORT: "3100" depends_on: - db diff --git a/frontend/src/components/TaskCard.tsx b/frontend/src/components/TaskCard.tsx index e77b2aa..77500d8 100644 --- a/frontend/src/components/TaskCard.tsx +++ b/frontend/src/components/TaskCard.tsx @@ -19,19 +19,15 @@ const sourceColors: Record = { const statusActions: Record = { active: [ { label: "โธ Pause", next: "queued" }, - { label: "๐Ÿšซ Block", next: "blocked" }, { label: "โœ… Complete", next: "completed" }, - { label: "โŒ Cancel", next: "cancelled" }, ], queued: [ { label: "โ–ถ Activate", next: "active" }, - { label: "๐Ÿšซ Block", next: "blocked" }, { label: "โŒ Cancel", next: "cancelled" }, ], blocked: [ { label: "โ–ถ Activate", next: "active" }, { label: "๐Ÿ“‹ Queue", next: "queued" }, - { label: "โŒ Cancel", next: "cancelled" }, ], completed: [{ label: "๐Ÿ”„ Requeue", next: "queued" }], cancelled: [{ label: "๐Ÿ”„ Requeue", next: "queued" }], @@ -56,6 +52,7 @@ interface TaskCardProps { isFirst?: boolean; isLast?: boolean; isActive?: boolean; + onClick?: () => void; } export function TaskCard({ @@ -66,77 +63,64 @@ export function TaskCard({ isFirst, isLast, isActive, + onClick, }: TaskCardProps) { const actions = statusActions[task.status] || []; + const noteCount = task.progressNotes?.length || 0; return (
-
+
{isActive && ( - - โšก ACTIVE + + + )} -

{task.title}

+

+ {task.title} +

{task.priority} - + {task.source} - created {timeAgo(task.createdAt)} + {timeAgo(task.createdAt)} - {task.updatedAt !== task.createdAt && ( - - ยท updated {timeAgo(task.updatedAt)} + {noteCount > 0 && ( + + ๐Ÿ’ฌ {noteCount} )}
{task.description && ( -

{task.description}

- )} - - {task.progressNotes && task.progressNotes.length > 0 && ( -
- - {task.progressNotes.length} progress note{task.progressNotes.length !== 1 ? "s" : ""} - -
- {task.progressNotes - .slice() - .reverse() - .map((note, i) => ( -
- {timeAgo(note.timestamp)}{" "} - โ€” {note.note} -
- ))} -
-
+

{task.description}

)}
-
+
e.stopPropagation()}> {/* Reorder buttons for queued tasks */} {task.status === "queued" && ( -
+
)} - {/* Status actions */} - {actions.map((action) => ( + {/* Quick status actions - show fewer on card, full set in detail */} + {actions.slice(0, 2).map((action) => ( ))} + + {/* Expand indicator */} +
+ + + +
diff --git a/frontend/src/components/TaskDetailPanel.tsx b/frontend/src/components/TaskDetailPanel.tsx new file mode 100644 index 0000000..d52ed99 --- /dev/null +++ b/frontend/src/components/TaskDetailPanel.tsx @@ -0,0 +1,288 @@ +import { useState, useEffect } from "react"; +import type { Task, TaskStatus, TaskPriority } from "../lib/types"; + +const priorityColors: Record = { + critical: "bg-red-500 text-white", + high: "bg-orange-500 text-white", + medium: "bg-blue-500 text-white", + low: "bg-gray-400 text-white", +}; + +const priorityIcons: Record = { + critical: "๐Ÿ”ด", + high: "๐ŸŸ ", + medium: "๐Ÿ”ต", + low: "โšช", +}; + +const statusColors: Record = { + active: "bg-amber-100 text-amber-800 border-amber-300", + queued: "bg-blue-100 text-blue-800 border-blue-300", + blocked: "bg-red-100 text-red-800 border-red-300", + completed: "bg-green-100 text-green-800 border-green-300", + cancelled: "bg-gray-100 text-gray-600 border-gray-300", +}; + +const statusIcons: Record = { + active: "โšก", + queued: "๐Ÿ“‹", + blocked: "๐Ÿšซ", + completed: "โœ…", + cancelled: "โŒ", +}; + +const sourceColors: Record = { + donovan: "bg-purple-100 text-purple-800", + david: "bg-green-100 text-green-800", + hammer: "bg-yellow-100 text-yellow-800", + heartbeat: "bg-pink-100 text-pink-800", + cron: "bg-indigo-100 text-indigo-800", + other: "bg-gray-100 text-gray-800", +}; + +const statusActions: Record = { + active: [ + { label: "โธ Pause", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }, + { label: "๐Ÿšซ Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100" }, + { label: "โœ… Complete", next: "completed", color: "bg-green-50 text-green-700 border-green-200 hover:bg-green-100" }, + { label: "โŒ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100" }, + ], + queued: [ + { label: "โ–ถ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" }, + { label: "๐Ÿšซ Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100" }, + { label: "โŒ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100" }, + ], + blocked: [ + { label: "โ–ถ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" }, + { label: "๐Ÿ“‹ Queue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }, + { label: "โŒ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100" }, + ], + completed: [{ label: "๐Ÿ”„ Requeue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }], + cancelled: [{ label: "๐Ÿ”„ Requeue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }], +}; + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatTimestamp(dateStr: string): string { + return new Date(dateStr).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function timeAgo(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function ElapsedTimer({ since }: { since: string }) { + const [elapsed, setElapsed] = useState(""); + + useEffect(() => { + const update = () => { + const diff = Math.floor((Date.now() - new Date(since).getTime()) / 1000); + const h = Math.floor(diff / 3600); + const m = Math.floor((diff % 3600) / 60); + const s = diff % 60; + if (h > 0) { + setElapsed(`${h}h ${m}m ${s}s`); + } else if (m > 0) { + setElapsed(`${m}m ${s}s`); + } else { + setElapsed(`${s}s`); + } + }; + update(); + const interval = setInterval(update, 1000); + return () => clearInterval(interval); + }, [since]); + + return ( + {elapsed} + ); +} + +interface TaskDetailPanelProps { + task: Task; + onClose: () => void; + onStatusChange: (id: string, status: TaskStatus) => void; + hasToken: boolean; +} + +export function TaskDetailPanel({ task, onClose, onStatusChange, hasToken }: TaskDetailPanelProps) { + const actions = statusActions[task.status] || []; + const isActive = task.status === "active"; + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+
+
+ {isActive && ( + + + + + )} + + {statusIcons[task.status]} {task.status.toUpperCase()} + + + {priorityIcons[task.priority]} {task.priority} + + + {task.source} + +
+

{task.title}

+
+ +
+
+ + {/* Content */} +
+ {/* Description */} + {task.description && ( +
+

Description

+

{task.description}

+
+ )} + + {/* Time Info */} +
+

Timeline

+
+
+ Created + {formatDate(task.createdAt)} ({timeAgo(task.createdAt)}) +
+ {task.updatedAt !== task.createdAt && ( +
+ Updated + {formatDate(task.updatedAt)} ({timeAgo(task.updatedAt)}) +
+ )} + {task.completedAt && ( +
+ Completed + {formatDate(task.completedAt)} +
+ )} + {isActive && ( +
+ โฑ Running for + +
+ )} +
+
+ + {/* Progress Notes */} +
+

+ Progress Notes {task.progressNotes?.length > 0 && ( + ({task.progressNotes.length}) + )} +

+ {!task.progressNotes || task.progressNotes.length === 0 ? ( +
+ No progress notes yet +
+ ) : ( +
+ {task.progressNotes + .slice() + .reverse() + .map((note, i) => ( +
+ {/* Timeline line */} + {i < task.progressNotes.length - 1 && ( +
+ )} + {/* Timeline dot */} +
+
+
+ {/* Content */} +
+

{note.note}

+

{formatTimestamp(note.timestamp)} ยท {timeAgo(note.timestamp)}

+
+
+ ))} +
+ )} +
+
+ + {/* Actions Footer */} + {hasToken && actions.length > 0 && ( +
+

Actions

+
+ {actions.map((action) => ( + + ))} +
+
+ )} + + {/* Task ID */} +
+

ID: {task.id}

+
+
+ + ); +}