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() {
-
+ setViewMode("board")}
+ >
Board
-
+ setViewMode("list")}
+ >
List
-
- Timeline
-
setIsDialogOpen(true)}>
New task
@@ -770,18 +1158,15 @@ export default function BoardDetailPage() {
) : null}
-
router.push(`/boards/${boardId}/edit`)}
+ className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
+ aria-label="Board settings"
+ title="Board settings"
>
- Board settings
-
-
router.push("/boards")}
- >
- Back to boards
-
+
+
@@ -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
+
+
+
setIsDialogOpen(true)}
+ disabled={isCreating}
+ >
+ New task
+
+
+
+
+ {displayTasks.length === 0 ? (
+
+ No tasks yet. Create your first task to get started.
+
+ ) : (
+ displayTasks.map((task) => (
+
openComments(task)}
+ >
+
+
+
+ {task.title}
+
+
+ {task.description
+ ? task.description
+ .toString()
+ .trim()
+ .slice(0, 120)
+ : "No description"}
+
+
+
+
+ {task.status.replace(/_/g, " ")}
+
+
+ {task.priority}
+
+
+ {task.assignee ?? "Unassigned"}
+
+
+ {formatTaskTimestamp(
+ task.updated_at ?? task.created_at,
+ )}
+
+
+
+
+ ))
+ )}
+
+
+ )}
+ >
)}
@@ -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}
+ setIsDeleteDialogOpen(true)}
+ disabled={!selectedTask || isSavingTask}
+ className="border-rose-200 text-rose-600 hover:border-rose-300 hover:text-rose-700"
+ >
+ Delete task
+
+
+
+
+ Delete task
+
+ This removes the task permanently. This action cannot be undone.
+
+
+ {deleteTaskError ? (
+
+ {deleteTaskError}
+
+ ) : null}
+
+ setIsDeleteDialogOpen(false)}
+ disabled={isDeletingTask}
+ >
+ Cancel
+
+
+ {isDeletingTask ? "Deleting…" : "Delete task"}
+
+
+
+
+
{
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
-
+
Refresh
@@ -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" ? (
-
-
handleDecision(approval.id, "approved")}
- disabled={updatingId === approval.id}
+
+ {sortedApprovals.pending.length > 0 ? (
+
+
+ Pending
+
+ {sortedApprovals.pending.map((approval) => {
+ const summary = approvalSummary(approval);
+ return (
+
- Approve
-
- handleDecision(approval.id, "rejected")}
- disabled={updatingId === approval.id}
- className={cn(
- "border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]"
- )}
- >
- Reject
-
-
- ) : 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}
+
+ handleDecision(approval.id, "approved")}
+ disabled={updatingId === approval.id}
+ >
+ Approve
+
+ handleDecision(approval.id, "rejected")}
+ disabled={updatingId === approval.id}
+ className={cn(
+ "border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]"
+ )}
+ >
+ Reject
+
+
+
+ );
+ })}
- ))}
+ ) : 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" ? (
-
-
- New task
-
-
- ) : 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: