From e09460a88184311bbbf6e4e155c45fda69f50fcc Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 5 Feb 2026 22:51:46 +0530 Subject: [PATCH] feat: enhance agent creation with human-like naming and improve task assignment notifications --- backend/app/api/agent.py | 6 +- backend/app/api/agents.py | 82 ++- backend/app/api/approvals.py | 87 ++- backend/app/api/tasks.py | 99 ++- backend/app/services/agent_provisioning.py | 16 +- frontend/src/app/boards/[boardId]/page.tsx | 596 +++++++++++++++++- .../src/components/BoardApprovalsPanel.tsx | 333 +++++++--- .../src/components/molecules/TaskCard.tsx | 98 ++- .../src/components/organisms/TaskBoard.tsx | 97 ++- templates/HEARTBEAT_LEAD.md | 1 + 10 files changed, 1212 insertions(+), 203 deletions(-) diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index b2c811c..e98bd37 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -332,7 +332,11 @@ async def agent_heartbeat( agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), ) -> AgentRead: if agent_ctx.agent.name != payload.name: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + payload = AgentHeartbeatCreate( + name=agent_ctx.agent.name, + status=payload.status, + board_id=payload.board_id, + ) return await agents_api.heartbeat_or_create_agent( # type: ignore[attr-defined] payload=payload, session=session, diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 80eaa1d..ad13c6a 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -1,17 +1,21 @@ from __future__ import annotations import re -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +import asyncio +import json from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import update +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from sqlalchemy import asc, or_, update from sqlmodel import Session, col, select +from sse_starlette.sse import EventSourceResponse +from starlette.concurrency import run_in_threadpool from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent from app.core.agent_tokens import generate_agent_token, hash_agent_token from app.core.auth import AuthContext -from app.db.session import get_session +from app.db.session import engine, get_session from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message from app.models.activity_events import ActivityEvent @@ -34,6 +38,22 @@ OFFLINE_AFTER = timedelta(minutes=10) AGENT_SESSION_PREFIX = "agent" +def _parse_since(value: str | None) -> datetime | None: + if not value: + return None + normalized = value.strip() + if not normalized: + return None + normalized = normalized.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is not None: + return parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed + + def _normalize_identity_profile( profile: dict[str, object] | None, ) -> dict[str, str] | None: @@ -172,6 +192,30 @@ def _with_computed_status(agent: Agent) -> Agent: return agent +def _serialize_agent(agent: Agent, main_session_keys: set[str]) -> dict[str, object]: + return _to_agent_read(_with_computed_status(agent), main_session_keys).model_dump() + + +def _fetch_agent_events( + board_id: UUID | None, + since: datetime, +) -> list[Agent]: + with Session(engine) as session: + statement = select(Agent) + if board_id: + statement = statement.where(col(Agent.board_id) == board_id) + statement = ( + statement.where( + or_( + col(Agent.updated_at) >= since, + col(Agent.last_seen_at) >= since, + ) + ) + .order_by(asc(col(Agent.updated_at))) + ) + return list(session.exec(statement)) + + def _record_heartbeat(session: Session, agent: Agent) -> None: record_activity( session, @@ -217,6 +261,36 @@ def list_agents( ] +@router.get("/stream") +async def stream_agents( + request: Request, + board_id: UUID | None = Query(default=None), + since: str | None = Query(default=None), + auth: AuthContext = Depends(require_admin_auth), +) -> EventSourceResponse: + since_dt = _parse_since(since) or datetime.utcnow() + last_seen = since_dt + + async def event_generator(): + nonlocal last_seen + while True: + if await request.is_disconnected(): + break + agents = await run_in_threadpool(_fetch_agent_events, board_id, last_seen) + if agents: + with Session(engine) as session: + main_session_keys = _get_gateway_main_session_keys(session) + for agent in agents: + updated_at = agent.updated_at or agent.last_seen_at or datetime.utcnow() + if updated_at > last_seen: + last_seen = updated_at + payload = {"agent": _serialize_agent(agent, main_session_keys)} + yield {"event": "agent", "data": json.dumps(payload)} + await asyncio.sleep(2) + + return EventSourceResponse(event_generator(), ping=15) + + @router.post("", response_model=AgentRead) async def create_agent( payload: AgentCreate, diff --git a/backend/app/api/approvals.py b/backend/app/api/approvals.py index 7910fd1..327a494 100644 --- a/backend/app/api/approvals.py +++ b/backend/app/api/approvals.py @@ -1,12 +1,18 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone +import asyncio +import json +from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from sqlalchemy import asc, or_ from sqlmodel import Session, col, select +from sse_starlette.sse import EventSourceResponse +from starlette.concurrency import run_in_threadpool from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent -from app.db.session import get_session +from app.db.session import engine, get_session from app.models.approvals import Approval from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalUpdate @@ -15,6 +21,49 @@ router = APIRouter(prefix="/boards/{board_id}/approvals", tags=["approvals"]) ALLOWED_STATUSES = {"pending", "approved", "rejected"} +def _parse_since(value: str | None) -> datetime | None: + if not value: + return None + normalized = value.strip() + if not normalized: + return None + normalized = normalized.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is not None: + return parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed + + +def _approval_updated_at(approval: Approval) -> datetime: + return approval.resolved_at or approval.created_at + + +def _serialize_approval(approval: Approval) -> dict[str, object]: + return ApprovalRead.model_validate(approval, from_attributes=True).model_dump() + + +def _fetch_approval_events( + board_id: UUID, + since: datetime, +) -> list[Approval]: + with Session(engine) as session: + statement = ( + select(Approval) + .where(col(Approval.board_id) == board_id) + .where( + or_( + col(Approval.created_at) >= since, + col(Approval.resolved_at) >= since, + ) + ) + .order_by(asc(col(Approval.created_at))) + ) + return list(session.exec(statement)) + + @router.get("", response_model=list[ApprovalRead]) def list_approvals( status_filter: str | None = Query(default=None, alias="status"), @@ -34,6 +83,38 @@ def list_approvals( return list(session.exec(statement)) +@router.get("/stream") +async def stream_approvals( + request: Request, + board=Depends(get_board_or_404), + actor: ActorContext = Depends(require_admin_or_agent), + since: str | None = Query(default=None), +) -> EventSourceResponse: + if actor.actor_type == "agent" and actor.agent: + if actor.agent.board_id and actor.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + since_dt = _parse_since(since) or datetime.utcnow() + last_seen = since_dt + + async def event_generator(): + nonlocal last_seen + while True: + if await request.is_disconnected(): + break + approvals = await run_in_threadpool( + _fetch_approval_events, board.id, last_seen + ) + for approval in approvals: + updated_at = _approval_updated_at(approval) + if updated_at > last_seen: + last_seen = updated_at + payload = {"approval": _serialize_approval(approval)} + yield {"event": "approval", "data": json.dumps(payload)} + await asyncio.sleep(2) + + return EventSourceResponse(event_generator(), ping=15) + + @router.post("", response_model=ApprovalRead) def create_approval( payload: ApprovalCreate, diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index d34b2db..c36eec3 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -9,7 +9,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sse_starlette.sse import EventSourceResponse from starlette.concurrency import run_in_threadpool -from sqlalchemy import asc, desc +from sqlalchemy import asc, desc, delete from sqlmodel import Session, col, select from app.api.deps import ( @@ -32,6 +32,7 @@ from app.models.agents import Agent from app.models.boards import Board from app.models.gateways import Gateway from app.models.tasks import Task +from app.models.task_fingerprints import TaskFingerprint from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.services.activity_log import record_activity @@ -150,6 +151,73 @@ async def _send_lead_task_message( await send_message(message, session_key=session_key, config=config, deliver=False) +async def _send_agent_task_message( + *, + session_key: str, + config: GatewayClientConfig, + agent_name: str, + message: str, +) -> None: + await ensure_session(session_key, config=config, label=agent_name) + await send_message(message, session_key=session_key, config=config, deliver=False) + + +def _notify_agent_on_task_assign( + *, + session: Session, + board: Board, + task: Task, + agent: Agent, +) -> None: + if not agent.openclaw_session_id: + return + config = _gateway_config(session, board) + if config is None: + return + description = (task.description or "").strip() + if len(description) > 500: + description = f"{description[:497]}..." + details = [ + f"Board: {board.name}", + f"Task: {task.title}", + f"Task ID: {task.id}", + f"Status: {task.status}", + ] + if description: + details.append(f"Description: {description}") + message = ( + "TASK ASSIGNED\n" + + "\n".join(details) + + "\n\nTake action: open the task and begin work. Post updates as task comments." + ) + try: + asyncio.run( + _send_agent_task_message( + session_key=agent.openclaw_session_id, + config=config, + agent_name=agent.name, + message=message, + ) + ) + record_activity( + session, + event_type="task.assignee_notified", + message=f"Agent notified for assignment: {agent.name}.", + agent_id=agent.id, + task_id=task.id, + ) + session.commit() + except OpenClawGatewayError as exc: + record_activity( + session, + event_type="task.assignee_notify_failed", + message=f"Assignee notify failed: {exc}", + agent_id=agent.id, + task_id=task.id, + ) + session.commit() + + def _notify_lead_on_task_create( *, session: Session, @@ -300,6 +368,15 @@ def create_task( ) session.commit() _notify_lead_on_task_create(session=session, board=board, task=task) + if task.assigned_agent_id: + assigned_agent = session.get(Agent, task.assigned_agent_id) + if assigned_agent: + _notify_agent_on_task_assign( + session=session, + board=board, + task=task, + agent=assigned_agent, + ) return task @@ -311,6 +388,7 @@ def update_task( actor: ActorContext = Depends(require_admin_or_agent), ) -> Task: previous_status = task.status + previous_assigned = task.assigned_agent_id updates = payload.model_dump(exclude_unset=True) comment = updates.pop("comment", None) if comment is not None and not comment.strip(): @@ -431,6 +509,23 @@ def update_task( agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None, ) session.commit() + if task.assigned_agent_id and task.assigned_agent_id != previous_assigned: + if ( + actor.actor_type == "agent" + and actor.agent + and task.assigned_agent_id == actor.agent.id + ): + return task + assigned_agent = session.get(Agent, task.assigned_agent_id) + if assigned_agent: + board = session.get(Board, task.board_id) if task.board_id else None + if board: + _notify_agent_on_task_assign( + session=session, + board=board, + task=task, + agent=assigned_agent, + ) return task @@ -440,6 +535,8 @@ def delete_task( task: Task = Depends(get_task_or_404), auth: AuthContext = Depends(require_admin_auth), ) -> dict[str, bool]: + session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id)) + session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id)) session.delete(task) session.commit() return {"ok": True} diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index d55a9f9..f001732 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -144,6 +144,9 @@ def _build_context( context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field]) for field, context_key in IDENTITY_PROFILE_FIELDS.items() } + preferred_name = (user.preferred_name or "") if user else "" + if preferred_name: + preferred_name = preferred_name.strip().split()[0] return { "agent_name": agent.name, "agent_id": agent_id, @@ -162,7 +165,7 @@ def _build_context( "main_session_key": main_session_key, "workspace_root": workspace_root, "user_name": (user.name or "") if user else "", - "user_preferred_name": (user.preferred_name or "") if user else "", + "user_preferred_name": preferred_name, "user_pronouns": (user.pronouns or "") if user else "", "user_timezone": (user.timezone or "") if user else "", "user_notes": (user.notes or "") if user else "", @@ -198,6 +201,9 @@ def _build_main_context( context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field]) for field, context_key in IDENTITY_PROFILE_FIELDS.items() } + preferred_name = (user.preferred_name or "") if user else "" + if preferred_name: + preferred_name = preferred_name.strip().split()[0] return { "agent_name": agent.name, "agent_id": str(agent.id), @@ -207,7 +213,7 @@ def _build_main_context( "main_session_key": gateway.main_session_key or "", "workspace_root": gateway.workspace_root or "", "user_name": (user.name or "") if user else "", - "user_preferred_name": (user.preferred_name or "") if user else "", + "user_preferred_name": preferred_name, "user_pronouns": (user.pronouns or "") if user else "", "user_timezone": (user.timezone or "") if user else "", "user_notes": (user.notes or "") if user else "", @@ -449,7 +455,8 @@ async def provision_agent( await _patch_gateway_agent_list(agent_id, workspace_path, heartbeat, client_config) context = _build_context(agent, board, gateway, auth_token, user) - supported = await _supported_gateway_files(client_config) + supported = set(await _supported_gateway_files(client_config)) + supported.add("USER.md") existing_files = await _gateway_agent_files_index(agent_id, client_config) include_bootstrap = True if action == "update" and not force_bootstrap: @@ -500,7 +507,8 @@ async def provision_main_agent( raise OpenClawGatewayError("Unable to resolve gateway main agent id") context = _build_main_context(agent, gateway, auth_token, user) - supported = await _supported_gateway_files(client_config) + supported = set(await _supported_gateway_files(client_config)) + supported.add("USER.md") existing_files = await _gateway_agent_files_index(agent_id, client_config) include_bootstrap = action != "update" or force_bootstrap if action == "update" and not force_bootstrap: diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 6d761e9..65ee83a 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; -import { Pencil, X } from "lucide-react"; +import { Pencil, Settings, X } from "lucide-react"; import ReactMarkdown from "react-markdown"; import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel"; @@ -61,6 +61,8 @@ type Agent = { status: string; board_id?: string | null; is_board_lead?: boolean; + updated_at?: string | null; + last_seen_at?: string | null; identity_profile?: { emoji?: string | null; } | null; @@ -130,8 +132,11 @@ export default function BoardDetailPage() { const [commentsError, setCommentsError] = useState(null); const [isDetailOpen, setIsDetailOpen] = useState(false); const tasksRef = useRef([]); + const approvalsRef = useRef([]); + const agentsRef = useRef([]); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isApprovalsOpen, setIsApprovalsOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [approvals, setApprovals] = useState([]); const [isApprovalsLoading, setIsApprovalsLoading] = useState(false); @@ -139,6 +144,9 @@ export default function BoardDetailPage() { const [approvalsUpdatingId, setApprovalsUpdatingId] = useState( null, ); + const [isDeletingTask, setIsDeletingTask] = useState(false); + const [deleteTaskError, setDeleteTaskError] = useState(null); + const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [isDialogOpen, setIsDialogOpen] = useState(false); const [title, setTitle] = useState(""); @@ -173,6 +181,32 @@ export default function BoardDetailPage() { return latestTime ? new Date(latestTime).toISOString() : null; }; + const latestApprovalTimestamp = (items: Approval[]) => { + let latestTime = 0; + items.forEach((approval) => { + const value = approval.resolved_at ?? approval.created_at; + if (!value) return; + const time = new Date(value).getTime(); + if (!Number.isNaN(time) && time > latestTime) { + latestTime = time; + } + }); + return latestTime ? new Date(latestTime).toISOString() : null; + }; + + const latestAgentTimestamp = (items: Agent[]) => { + let latestTime = 0; + items.forEach((agent) => { + const value = agent.updated_at ?? agent.last_seen_at; + if (!value) return; + const time = new Date(value).getTime(); + if (!Number.isNaN(time) && time > latestTime) { + latestTime = time; + } + }); + return latestTime ? new Date(latestTime).toISOString() : null; + }; + const loadBoard = async () => { if (!isSignedIn || !boardId) return; setIsLoading(true); @@ -229,6 +263,14 @@ export default function BoardDetailPage() { tasksRef.current = tasks; }, [tasks]); + useEffect(() => { + approvalsRef.current = approvals; + }, [approvals]); + + useEffect(() => { + agentsRef.current = agents; + }, [agents]); + const loadApprovals = useCallback(async () => { if (!isSignedIn || !boardId) return; setIsApprovalsLoading(true); @@ -259,11 +301,96 @@ export default function BoardDetailPage() { useEffect(() => { loadApprovals(); - if (!isSignedIn || !boardId) return; - const interval = setInterval(loadApprovals, 15000); - return () => clearInterval(interval); }, [boardId, isSignedIn, loadApprovals]); + useEffect(() => { + if (!isSignedIn || !boardId) return; + let isCancelled = false; + const abortController = new AbortController(); + + const connect = async () => { + try { + const token = await getToken(); + if (!token || isCancelled) return; + const url = new URL( + `${apiBase}/api/v1/boards/${boardId}/approvals/stream`, + ); + const since = latestApprovalTimestamp(approvalsRef.current); + if (since) { + url.searchParams.set("since", since); + } + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${token}`, + }, + signal: abortController.signal, + }); + if (!response.ok || !response.body) { + throw new Error("Unable to connect approvals stream."); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (!isCancelled) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + buffer = buffer.replace(/\r\n/g, "\n"); + let boundary = buffer.indexOf("\n\n"); + while (boundary !== -1) { + const raw = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const lines = raw.split("\n"); + let eventType = "message"; + let data = ""; + for (const line of lines) { + if (line.startsWith("event:")) { + eventType = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + data += line.slice(5).trim(); + } + } + if (eventType === "approval" && data) { + try { + const payload = JSON.parse(data) as { approval?: Approval }; + if (payload.approval) { + setApprovals((prev) => { + const index = prev.findIndex( + (item) => item.id === payload.approval?.id, + ); + if (index === -1) { + return [payload.approval as Approval, ...prev]; + } + const next = [...prev]; + next[index] = { + ...next[index], + ...(payload.approval as Approval), + }; + return next; + }); + } + } catch { + // Ignore malformed payloads. + } + } + boundary = buffer.indexOf("\n\n"); + } + } + } catch { + if (!isCancelled) { + setTimeout(connect, 3000); + } + } + }; + + connect(); + return () => { + isCancelled = true; + abortController.abort(); + }; + }, [boardId, getToken, isSignedIn]); + useEffect(() => { if (!selectedTask) { setEditTitle(""); @@ -378,6 +505,93 @@ export default function BoardDetailPage() { }; }, [board, boardId, getToken, isSignedIn, selectedTask?.id]); + useEffect(() => { + if (!isSignedIn || !boardId) return; + let isCancelled = false; + const abortController = new AbortController(); + + const connect = async () => { + try { + const token = await getToken(); + if (!token || isCancelled) return; + const url = new URL(`${apiBase}/api/v1/agents/stream`); + url.searchParams.set("board_id", boardId); + const since = latestAgentTimestamp(agentsRef.current); + if (since) { + url.searchParams.set("since", since); + } + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${token}`, + }, + signal: abortController.signal, + }); + if (!response.ok || !response.body) { + throw new Error("Unable to connect agent stream."); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (!isCancelled) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + buffer = buffer.replace(/\r\n/g, "\n"); + let boundary = buffer.indexOf("\n\n"); + while (boundary !== -1) { + const raw = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const lines = raw.split("\n"); + let eventType = "message"; + let data = ""; + for (const line of lines) { + if (line.startsWith("event:")) { + eventType = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + data += line.slice(5).trim(); + } + } + if (eventType === "agent" && data) { + try { + const payload = JSON.parse(data) as { agent?: Agent }; + if (payload.agent) { + setAgents((prev) => { + const index = prev.findIndex( + (item) => item.id === payload.agent?.id, + ); + if (index === -1) { + return [payload.agent as Agent, ...prev]; + } + const next = [...prev]; + next[index] = { + ...next[index], + ...(payload.agent as Agent), + }; + return next; + }); + } + } catch { + // Ignore malformed payloads. + } + } + boundary = buffer.indexOf("\n\n"); + } + } + } catch { + if (!isCancelled) { + setTimeout(connect, 3000); + } + } + }; + + connect(); + return () => { + isCancelled = true; + abortController.abort(); + }; + }, [boardId, getToken, isSignedIn]); + const resetForm = () => { setTitle(""); setDescription(""); @@ -622,6 +836,79 @@ export default function BoardDetailPage() { setSaveTaskError(null); }; + const handleDeleteTask = async () => { + if (!selectedTask || !boardId || !isSignedIn) return; + setIsDeletingTask(true); + setDeleteTaskError(null); + try { + const token = await getToken(); + const response = await fetch( + `${apiBase}/api/v1/boards/${boardId}/tasks/${selectedTask.id}`, + { + method: "DELETE", + headers: { + Authorization: token ? `Bearer ${token}` : "", + }, + }, + ); + if (!response.ok) { + throw new Error("Unable to delete task."); + } + setTasks((prev) => prev.filter((task) => task.id !== selectedTask.id)); + setIsDeleteDialogOpen(false); + closeComments(); + } catch (err) { + setDeleteTaskError( + err instanceof Error ? err.message : "Something went wrong.", + ); + } finally { + setIsDeletingTask(false); + } + }; + + const handleTaskMove = async (taskId: string, status: string) => { + if (!isSignedIn || !boardId) return; + const currentTask = tasksRef.current.find((task) => task.id === taskId); + if (!currentTask || currentTask.status === status) return; + const previousTasks = tasksRef.current; + setTasks((prev) => + prev.map((task) => + task.id === taskId + ? { + ...task, + status, + assigned_agent_id: + status === "inbox" ? null : task.assigned_agent_id, + } + : task, + ), + ); + try { + const token = await getToken(); + const response = await fetch( + `${apiBase}/api/v1/boards/${boardId}/tasks/${taskId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: token ? `Bearer ${token}` : "", + }, + body: JSON.stringify({ status }), + }, + ); + if (!response.ok) { + throw new Error("Unable to move task."); + } + const updated = (await response.json()) as Task; + setTasks((prev) => + prev.map((task) => (task.id === updated.id ? updated : task)), + ); + } catch (err) { + setTasks(previousTasks); + setError(err instanceof Error ? err.message : "Unable to move task."); + } + }; + const agentInitials = (agent: Agent) => agent.name .split(" ") @@ -664,6 +951,44 @@ export default function BoardDetailPage() { }); }; + const formatTaskTimestamp = (value?: string | null) => { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const statusBadgeClass = (value?: string) => { + switch (value) { + case "in_progress": + return "bg-purple-100 text-purple-700"; + case "review": + return "bg-indigo-100 text-indigo-700"; + case "done": + return "bg-emerald-100 text-emerald-700"; + default: + return "bg-slate-100 text-slate-600"; + } + }; + + const priorityBadgeClass = (value?: string) => { + switch (value?.toLowerCase()) { + case "high": + return "bg-rose-100 text-rose-700"; + case "medium": + return "bg-amber-100 text-amber-700"; + case "low": + return "bg-emerald-100 text-emerald-700"; + default: + return "bg-slate-100 text-slate-600"; + } + }; + const formatApprovalTimestamp = (value?: string | null) => { if (!value) return "—"; const date = new Date(value); @@ -676,6 +1001,56 @@ export default function BoardDetailPage() { }); }; + const humanizeApprovalAction = (value: string) => + value + .split(".") + .map((part) => + part + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) + ) + .join(" · "); + + const approvalPayloadValue = ( + payload: Approval["payload"], + key: string, + ) => { + if (!payload) return null; + const value = payload[key as keyof typeof payload]; + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + return null; + }; + + const approvalRows = (approval: Approval) => { + const payload = approval.payload ?? {}; + const taskId = + approvalPayloadValue(payload, "task_id") ?? + approvalPayloadValue(payload, "taskId") ?? + approvalPayloadValue(payload, "taskID"); + const assignedAgentId = + approvalPayloadValue(payload, "assigned_agent_id") ?? + approvalPayloadValue(payload, "assignedAgentId"); + const title = approvalPayloadValue(payload, "title"); + const role = approvalPayloadValue(payload, "role"); + const isAssign = approval.action_type.includes("assign"); + const rows: Array<{ label: string; value: string }> = []; + if (taskId) rows.push({ label: "Task", value: taskId }); + if (isAssign) { + rows.push({ + label: "Assignee", + value: assignedAgentId ?? "Unassigned", + }); + } + if (title) rows.push({ label: "Title", value: title }); + if (role) rows.push({ label: "Role", value: role }); + return rows; + }; + + const approvalReason = (approval: Approval) => + approvalPayloadValue(approval.payload ?? {}, "reason"); + const handleApprovalDecision = useCallback( async (approvalId: string, status: "approved" | "rejected") => { if (!isSignedIn || !boardId) return; @@ -745,15 +1120,28 @@ export default function BoardDetailPage() {
- - -
- - + +
@@ -863,12 +1248,98 @@ export default function BoardDetailPage() { Loading {titleLabel}… ) : ( - setIsDialogOpen(true)} - isCreateDisabled={isCreating} - onTaskSelect={openComments} - /> + <> + {viewMode === "board" ? ( + setIsDialogOpen(true)} + isCreateDisabled={isCreating} + onTaskSelect={openComments} + onTaskMove={handleTaskMove} + /> + ) : ( +
+
+
+
+

+ All tasks +

+

+ {displayTasks.length} tasks in this board +

+
+ +
+
+
+ {displayTasks.length === 0 ? ( +
+ No tasks yet. Create your first task to get started. +
+ ) : ( + displayTasks.map((task) => ( + + )) + )} +
+
+ )} + )} @@ -956,7 +1427,7 @@ export default function BoardDetailPage() {

- {approval.action_type.replace(/_/g, " ")} + {humanizeApprovalAction(approval.action_type)}

Requested {formatApprovalTimestamp(approval.created_at)} @@ -966,10 +1437,24 @@ export default function BoardDetailPage() { {approval.confidence}% confidence · {approval.status}

- {approval.payload ? ( -
-                          {JSON.stringify(approval.payload, null, 2)}
-                        
+ {approvalRows(approval).length > 0 ? ( +
+ {approvalRows(approval).map((row) => ( +
+

+ {row.label} +

+

+ {row.value} +

+
+ ))} +
+ ) : null} + {approvalReason(approval) ? ( +

+ {approvalReason(approval)} +

) : null} {approval.status === "pending" ? (
@@ -1082,7 +1567,16 @@ export default function BoardDetailPage() { Review pending decisions from your lead agent. - {boardId ? : null} + {boardId ? ( + + ) : null} @@ -1198,6 +1692,14 @@ export default function BoardDetailPage() { ) : null}
+ + + + + + { diff --git a/frontend/src/components/BoardApprovalsPanel.tsx b/frontend/src/components/BoardApprovalsPanel.tsx index 1a7b7df..20deb15 100644 --- a/frontend/src/components/BoardApprovalsPanel.tsx +++ b/frontend/src/components/BoardApprovalsPanel.tsx @@ -25,6 +25,11 @@ type Approval = { type BoardApprovalsPanelProps = { boardId: string; + approvals?: Approval[]; + isLoading?: boolean; + error?: string | null; + onRefresh?: () => void; + onDecision?: (approvalId: string, status: "approved" | "rejected") => void; }; const formatTimestamp = (value?: string | null) => { @@ -51,14 +56,71 @@ const confidenceVariant = (confidence: number) => { return "warning"; }; -export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) { +const humanizeAction = (value: string) => + value + .split(".") + .map((part) => + part + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) + ) + .join(" · "); + +const payloadValue = (payload: Approval["payload"], key: string) => { + if (!payload) return null; + const value = payload[key as keyof typeof payload]; + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + return null; +}; + +const approvalSummary = (approval: Approval) => { + const payload = approval.payload ?? {}; + const taskId = + payloadValue(payload, "task_id") ?? + payloadValue(payload, "taskId") ?? + payloadValue(payload, "taskID"); + const assignedAgentId = + payloadValue(payload, "assigned_agent_id") ?? + payloadValue(payload, "assignedAgentId"); + const reason = payloadValue(payload, "reason"); + const title = payloadValue(payload, "title"); + const role = payloadValue(payload, "role"); + const isAssign = approval.action_type.includes("assign"); + const rows: Array<{ label: string; value: string }> = []; + if (taskId) rows.push({ label: "Task", value: taskId }); + if (isAssign) { + rows.push({ + label: "Assignee", + value: assignedAgentId ?? "Unassigned", + }); + } + if (title) rows.push({ label: "Title", value: title }); + if (role) rows.push({ label: "Role", value: role }); + return { taskId, reason, rows }; +}; + +export function BoardApprovalsPanel({ + boardId, + approvals: externalApprovals, + isLoading: externalLoading, + error: externalError, + onRefresh, + onDecision, +}: BoardApprovalsPanelProps) { const { getToken, isSignedIn } = useAuth(); - const [approvals, setApprovals] = useState([]); + const [internalApprovals, setInternalApprovals] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [updatingId, setUpdatingId] = useState(null); + const usingExternal = Array.isArray(externalApprovals); + const approvals = usingExternal ? externalApprovals ?? [] : internalApprovals; + const loadingState = usingExternal ? externalLoading ?? false : isLoading; + const errorState = usingExternal ? externalError ?? null : error; const loadApprovals = useCallback(async () => { + if (usingExternal) return; if (!isSignedIn || !boardId) return; setIsLoading(true); setError(null); @@ -71,23 +133,29 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) { }); if (!res.ok) throw new Error("Unable to load approvals."); const data = (await res.json()) as Approval[]; - setApprovals(data); + setInternalApprovals(data); } catch (err) { setError(err instanceof Error ? err.message : "Unable to load approvals."); } finally { setIsLoading(false); } - }, [boardId, getToken, isSignedIn]); + }, [boardId, getToken, isSignedIn, usingExternal]); useEffect(() => { + if (usingExternal) return; loadApprovals(); if (!isSignedIn || !boardId) return; const interval = setInterval(loadApprovals, 15000); return () => clearInterval(interval); - }, [boardId, isSignedIn, loadApprovals]); + }, [boardId, isSignedIn, loadApprovals, usingExternal]); const handleDecision = useCallback( async (approvalId: string, status: "approved" | "rejected") => { + if (onDecision) { + onDecision(approvalId, status); + return; + } + if (usingExternal) return; if (!isSignedIn || !boardId) return; setUpdatingId(approvalId); setError(null); @@ -106,7 +174,7 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) { ); if (!res.ok) throw new Error("Unable to update approval."); const updated = (await res.json()) as Approval; - setApprovals((prev) => + setInternalApprovals((prev) => prev.map((item) => (item.id === approvalId ? updated : item)) ); } catch (err) { @@ -117,19 +185,23 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) { setUpdatingId(null); } }, - [boardId, getToken, isSignedIn] + [boardId, getToken, isSignedIn, onDecision, usingExternal] ); const sortedApprovals = useMemo(() => { - const pending = approvals.filter((item) => item.status === "pending"); - const resolved = approvals.filter((item) => item.status !== "pending"); const sortByTime = (items: Approval[]) => [...items].sort((a, b) => { const aTime = new Date(a.created_at).getTime(); const bTime = new Date(b.created_at).getTime(); return bTime - aTime; }); - return [...sortByTime(pending), ...sortByTime(resolved)]; + const pending = sortByTime( + approvals.filter((item) => item.status === "pending") + ); + const resolved = sortByTime( + approvals.filter((item) => item.status !== "pending") + ); + return { pending, resolved }; }, [approvals]); return ( @@ -141,10 +213,14 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) { Approvals

- Pending decisions + {sortedApprovals.pending.length} pending

- @@ -153,82 +229,179 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {

- {error ? ( + {errorState ? (
- {error} + {errorState}
) : null} - {isLoading ? ( + {loadingState ? (

Loading approvals…

- ) : sortedApprovals.length === 0 ? ( + ) : sortedApprovals.pending.length === 0 && + sortedApprovals.resolved.length === 0 ? (

No approvals yet.

) : ( -
- {sortedApprovals.map((approval) => ( -
-
-
-

- {approval.action_type.replace(/_/g, " ")} -

-

- Requested {formatTimestamp(approval.created_at)} -

-
-
- - {approval.confidence}% confidence - - - {approval.status} - -
-
- {approval.payload || approval.rubric_scores ? ( -
- - Details - - {approval.payload ? ( -
-                        Payload: {JSON.stringify(approval.payload, null, 2)}
-                      
- ) : null} - {approval.rubric_scores ? ( -
-                        Rubric: {JSON.stringify(approval.rubric_scores, null, 2)}
-                      
- ) : null} -
- ) : null} - {approval.status === "pending" ? ( -
- - -
- ) : null} +
+
+

+ {humanizeAction(approval.action_type)} +

+

+ Requested {formatTimestamp(approval.created_at)} +

+
+
+ + {approval.confidence}% confidence + + + {approval.status} + +
+
+ {summary.rows.length > 0 ? ( +
+ {summary.rows.map((row) => ( +
+

+ {row.label} +

+

+ {row.value} +

+
+ ))} +
+ ) : null} + {summary.reason ? ( +

{summary.reason}

+ ) : null} + {approval.payload || approval.rubric_scores ? ( +
+ + Details + + {approval.payload ? ( +
+                              Payload: {JSON.stringify(approval.payload, null, 2)}
+                            
+ ) : null} + {approval.rubric_scores ? ( +
+                              Rubric:{" "}
+                              {JSON.stringify(approval.rubric_scores, null, 2)}
+                            
+ ) : null} +
+ ) : null} +
+ + +
+
+ ); + })}
- ))} + ) : null} + {sortedApprovals.resolved.length > 0 ? ( +
+

+ Resolved +

+ {sortedApprovals.resolved.map((approval) => { + const summary = approvalSummary(approval); + return ( +
+
+
+

+ {humanizeAction(approval.action_type)} +

+

+ Requested {formatTimestamp(approval.created_at)} +

+
+
+ + {approval.confidence}% confidence + + + {approval.status} + +
+
+ {summary.rows.length > 0 ? ( +
+ {summary.rows.map((row) => ( +
+

+ {row.label} +

+

+ {row.value} +

+
+ ))} +
+ ) : null} + {summary.reason ? ( +

{summary.reason}

+ ) : null} + {approval.payload || approval.rubric_scores ? ( +
+ + Details + + {approval.payload ? ( +
+                              Payload: {JSON.stringify(approval.payload, null, 2)}
+                            
+ ) : null} + {approval.rubric_scores ? ( +
+                              Rubric:{" "}
+                              {JSON.stringify(approval.rubric_scores, null, 2)}
+                            
+ ) : null} +
+ ) : null} +
+ ); + })} +
+ ) : null} )}
diff --git a/frontend/src/components/molecules/TaskCard.tsx b/frontend/src/components/molecules/TaskCard.tsx index 4931b64..ed0c172 100644 --- a/frontend/src/components/molecules/TaskCard.tsx +++ b/frontend/src/components/molecules/TaskCard.tsx @@ -4,71 +4,53 @@ import { cn } from "@/lib/utils"; interface TaskCardProps { title: string; - status: string; + priority?: string; assignee?: string; due?: string; onClick?: () => void; + draggable?: boolean; + isDragging?: boolean; + onDragStart?: (event: React.DragEvent) => void; + onDragEnd?: (event: React.DragEvent) => void; } export function TaskCard({ title, - status, + priority, assignee, due, onClick, + draggable = false, + isDragging = false, + onDragStart, + onDragEnd, }: TaskCardProps) { - const statusConfig: Record< - string, - { label: string; dot: string; badge: string; text: string } - > = { - inbox: { - label: "Inbox", - dot: "bg-slate-400", - badge: "bg-slate-100", - text: "text-slate-600", - }, - assigned: { - label: "Assigned", - dot: "bg-blue-500", - badge: "bg-blue-50", - text: "text-blue-700", - }, - in_progress: { - label: "In progress", - dot: "bg-purple-500", - badge: "bg-purple-50", - text: "text-purple-700", - }, - testing: { - label: "Testing", - dot: "bg-amber-500", - badge: "bg-amber-50", - text: "text-amber-700", - }, - review: { - label: "Review", - dot: "bg-indigo-500", - badge: "bg-indigo-50", - text: "text-indigo-700", - }, - done: { - label: "Done", - dot: "bg-green-500", - badge: "bg-green-50", - text: "text-green-700", - }, + const priorityBadge = (value?: string) => { + if (!value) return null; + const normalized = value.toLowerCase(); + if (normalized === "high") { + return "bg-rose-100 text-rose-700"; + } + if (normalized === "medium") { + return "bg-amber-100 text-amber-700"; + } + if (normalized === "low") { + return "bg-emerald-100 text-emerald-700"; + } + return "bg-slate-100 text-slate-600"; }; - const config = statusConfig[status] ?? { - label: status, - dot: "bg-slate-400", - badge: "bg-slate-100", - text: "text-slate-600", - }; + const priorityLabel = priority ? priority.toUpperCase() : "MEDIUM"; return (
- - - {config.label} -

{title}

+ + {priorityLabel} +
diff --git a/frontend/src/components/organisms/TaskBoard.tsx b/frontend/src/components/organisms/TaskBoard.tsx index baf8e66..46197e7 100644 --- a/frontend/src/components/organisms/TaskBoard.tsx +++ b/frontend/src/components/organisms/TaskBoard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { TaskCard } from "@/components/molecules/TaskCard"; import { cn } from "@/lib/utils"; @@ -21,6 +21,7 @@ type TaskBoardProps = { onCreateTask: () => void; isCreateDisabled?: boolean; onTaskSelect?: (task: Task) => void; + onTaskMove?: (taskId: string, status: string) => void; }; const columns = [ @@ -30,6 +31,7 @@ const columns = [ dot: "bg-slate-400", accent: "hover:border-slate-400 hover:bg-slate-50", text: "group-hover:text-slate-700 text-slate-500", + badge: "bg-slate-100 text-slate-600", }, { title: "In Progress", @@ -37,6 +39,7 @@ const columns = [ dot: "bg-purple-500", accent: "hover:border-purple-400 hover:bg-purple-50", text: "group-hover:text-purple-600 text-slate-500", + badge: "bg-purple-100 text-purple-700", }, { title: "Review", @@ -44,6 +47,7 @@ const columns = [ dot: "bg-indigo-500", accent: "hover:border-indigo-400 hover:bg-indigo-50", text: "group-hover:text-indigo-600 text-slate-500", + badge: "bg-indigo-100 text-indigo-700", }, { title: "Done", @@ -51,6 +55,7 @@ const columns = [ dot: "bg-green-500", accent: "hover:border-green-400 hover:bg-green-50", text: "group-hover:text-green-600 text-slate-500", + badge: "bg-emerald-100 text-emerald-700", }, ]; @@ -69,7 +74,11 @@ export function TaskBoard({ onCreateTask, isCreateDisabled = false, onTaskSelect, + onTaskMove, }: TaskBoardProps) { + const [draggingId, setDraggingId] = useState(null); + const [activeColumn, setActiveColumn] = useState(null); + const grouped = useMemo(() => { const buckets: Record = {}; for (const column of columns) { @@ -82,12 +91,67 @@ export function TaskBoard({ return buckets; }, [tasks]); + const handleDragStart = + (task: Task) => (event: React.DragEvent) => { + setDraggingId(task.id); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData( + "text/plain", + JSON.stringify({ taskId: task.id, status: task.status }), + ); + }; + + const handleDragEnd = () => { + setDraggingId(null); + setActiveColumn(null); + }; + + const handleDrop = + (status: string) => (event: React.DragEvent) => { + event.preventDefault(); + setActiveColumn(null); + const raw = event.dataTransfer.getData("text/plain"); + if (!raw) return; + try { + const payload = JSON.parse(raw) as { taskId?: string; status?: string }; + if (!payload.taskId || !payload.status) return; + if (payload.status === status) return; + onTaskMove?.(payload.taskId, status); + } catch { + // Ignore malformed payloads. + } + }; + + const handleDragOver = + (status: string) => (event: React.DragEvent) => { + event.preventDefault(); + if (activeColumn !== status) { + setActiveColumn(status); + } + }; + + const handleDragLeave = + (status: string) => (_event: React.DragEvent) => { + if (activeColumn === status) { + setActiveColumn(null); + } + }; + return (
{columns.map((column) => { const columnTasks = grouped[column.status] ?? []; return ( -
+
@@ -96,37 +160,30 @@ export function TaskBoard({ {column.title}
- + {columnTasks.length}
- {column.status === "inbox" ? ( - - ) : null}
{columnTasks.map((task) => ( onTaskSelect?.(task)} + draggable + isDragging={draggingId === task.id} + onDragStart={handleDragStart(task)} + onDragEnd={handleDragEnd} /> ))}
diff --git a/templates/HEARTBEAT_LEAD.md b/templates/HEARTBEAT_LEAD.md index 25c2d77..8dbbb17 100644 --- a/templates/HEARTBEAT_LEAD.md +++ b/templates/HEARTBEAT_LEAD.md @@ -72,6 +72,7 @@ If any required input is missing, stop and request a provisioning update. - If workload or skills coverage is insufficient, create a new agent. - Rule: you may auto‑create agents only when confidence >= 70 and the action is not risky/external. - If risky/external or confidence < 70, create an approval instead. + - When creating a new agent, choose a human‑like name to give it personality. Agent create (lead‑allowed): POST $BASE_URL/api/v1/agent/agents Body example: