feat: enhance agent creation with human-like naming and improve task assignment notifications

This commit is contained in:
Abhimanyu Saharan
2026-02-05 22:51:46 +05:30
parent cbf9fd1b0a
commit e09460a881
10 changed files with 1212 additions and 203 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const tasksRef = useRef<Task[]>([]);
const approvalsRef = useRef<Approval[]>([]);
const agentsRef = useRef<Agent[]>([]);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isApprovalsOpen, setIsApprovalsOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [approvals, setApprovals] = useState<Approval[]>([]);
const [isApprovalsLoading, setIsApprovalsLoading] = useState(false);
@@ -139,6 +144,9 @@ export default function BoardDetailPage() {
const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>(
null,
);
const [isDeletingTask, setIsDeletingTask] = useState(false);
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(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() {
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-1">
<button className="rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white">
<button
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
viewMode === "board"
? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-200 hover:text-slate-900",
)}
onClick={() => setViewMode("board")}
>
Board
</button>
<button className="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900">
<button
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
viewMode === "list"
? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-200 hover:text-slate-900",
)}
onClick={() => setViewMode("list")}
>
List
</button>
<button className="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900">
Timeline
</button>
</div>
<Button onClick={() => setIsDialogOpen(true)}>
New task
@@ -770,18 +1158,15 @@ export default function BoardDetailPage() {
</span>
) : null}
</Button>
<Button
variant="outline"
<button
type="button"
onClick={() => 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
</Button>
<Button
variant="outline"
onClick={() => router.push("/boards")}
>
Back to boards
</Button>
<Settings className="h-4 w-4" />
</button>
</div>
</div>
</div>
@@ -863,12 +1248,98 @@ export default function BoardDetailPage() {
Loading {titleLabel}
</div>
) : (
<TaskBoard
tasks={displayTasks}
onCreateTask={() => setIsDialogOpen(true)}
isCreateDisabled={isCreating}
onTaskSelect={openComments}
/>
<>
{viewMode === "board" ? (
<TaskBoard
tasks={displayTasks}
onCreateTask={() => setIsDialogOpen(true)}
isCreateDisabled={isCreating}
onTaskSelect={openComments}
onTaskMove={handleTaskMove}
/>
) : (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 px-5 py-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-slate-900">
All tasks
</p>
<p className="text-xs text-slate-500">
{displayTasks.length} tasks in this board
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsDialogOpen(true)}
disabled={isCreating}
>
New task
</Button>
</div>
</div>
<div className="divide-y divide-slate-100">
{displayTasks.length === 0 ? (
<div className="px-5 py-8 text-sm text-slate-500">
No tasks yet. Create your first task to get started.
</div>
) : (
displayTasks.map((task) => (
<button
key={task.id}
type="button"
className="w-full px-5 py-4 text-left transition hover:bg-slate-50"
onClick={() => openComments(task)}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900">
{task.title}
</p>
<p className="mt-1 text-xs text-slate-500">
{task.description
? task.description
.toString()
.trim()
.slice(0, 120)
: "No description"}
</p>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
<span
className={cn(
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
statusBadgeClass(task.status),
)}
>
{task.status.replace(/_/g, " ")}
</span>
<span
className={cn(
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
priorityBadgeClass(task.priority),
)}
>
{task.priority}
</span>
<span className="text-xs text-slate-500">
{task.assignee ?? "Unassigned"}
</span>
<span className="text-xs text-slate-500">
{formatTaskTimestamp(
task.updated_at ?? task.created_at,
)}
</span>
</div>
</div>
</button>
))
)}
</div>
</div>
)}
</>
)}
</div>
</div>
@@ -956,7 +1427,7 @@ export default function BoardDetailPage() {
<div className="flex flex-wrap items-start justify-between gap-2 text-xs text-slate-500">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
{approval.action_type.replace(/_/g, " ")}
{humanizeApprovalAction(approval.action_type)}
</p>
<p className="mt-1 text-xs text-slate-500">
Requested {formatApprovalTimestamp(approval.created_at)}
@@ -966,10 +1437,24 @@ export default function BoardDetailPage() {
{approval.confidence}% confidence · {approval.status}
</span>
</div>
{approval.payload ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-slate-600">
{JSON.stringify(approval.payload, null, 2)}
</pre>
{approvalRows(approval).length > 0 ? (
<div className="mt-2 grid gap-2 text-xs text-slate-600 sm:grid-cols-2">
{approvalRows(approval).map((row) => (
<div key={`${approval.id}-${row.label}`}>
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
{row.label}
</p>
<p className="mt-1 text-xs text-slate-700">
{row.value}
</p>
</div>
))}
</div>
) : null}
{approvalReason(approval) ? (
<p className="mt-2 text-xs text-slate-600">
{approvalReason(approval)}
</p>
) : null}
{approval.status === "pending" ? (
<div className="mt-3 flex flex-wrap gap-2">
@@ -1082,7 +1567,16 @@ export default function BoardDetailPage() {
Review pending decisions from your lead agent.
</DialogDescription>
</DialogHeader>
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null}
{boardId ? (
<BoardApprovalsPanel
boardId={boardId}
approvals={approvals}
isLoading={isApprovalsLoading}
error={approvalsError}
onDecision={handleApprovalDecision}
onRefresh={loadApprovals}
/>
) : null}
</DialogContent>
</Dialog>
@@ -1198,6 +1692,14 @@ export default function BoardDetailPage() {
) : null}
</div>
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(true)}
disabled={!selectedTask || isSavingTask}
className="border-rose-200 text-rose-600 hover:border-rose-300 hover:text-rose-700"
>
Delete task
</Button>
<Button
variant="outline"
onClick={handleTaskReset}
@@ -1215,6 +1717,38 @@ export default function BoardDetailPage() {
</DialogContent>
</Dialog>
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent aria-label="Delete task">
<DialogHeader>
<DialogTitle>Delete task</DialogTitle>
<DialogDescription>
This removes the task permanently. This action cannot be undone.
</DialogDescription>
</DialogHeader>
{deleteTaskError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-600">
{deleteTaskError}
</div>
) : null}
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
disabled={isDeletingTask}
>
Cancel
</Button>
<Button
onClick={handleDeleteTask}
disabled={isDeletingTask}
className="bg-rose-600 text-white hover:bg-rose-700"
>
{isDeletingTask ? "Deleting…" : "Delete task"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isDialogOpen}
onOpenChange={(nextOpen) => {

View File

@@ -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<Approval[]>([]);
const [internalApprovals, setInternalApprovals] = useState<Approval[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [updatingId, setUpdatingId] = useState<string | null>(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
</p>
<p className="mt-1 text-lg font-semibold text-strong">
Pending decisions
{sortedApprovals.pending.length} pending
</p>
</div>
<Button variant="secondary" size="sm" onClick={loadApprovals}>
<Button
variant="secondary"
size="sm"
onClick={onRefresh ?? loadApprovals}
>
Refresh
</Button>
</div>
@@ -153,82 +229,179 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
</p>
</CardHeader>
<CardContent className="space-y-4 pt-5">
{error ? (
{errorState ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
{errorState}
</div>
) : null}
{isLoading ? (
{loadingState ? (
<p className="text-sm text-muted">Loading approvals</p>
) : sortedApprovals.length === 0 ? (
) : sortedApprovals.pending.length === 0 &&
sortedApprovals.resolved.length === 0 ? (
<p className="text-sm text-muted">No approvals yet.</p>
) : (
<div className="space-y-4">
{sortedApprovals.map((approval) => (
<div
key={approval.id}
className="space-y-2 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-strong">
{approval.action_type.replace(/_/g, " ")}
</p>
<p className="text-xs text-muted">
Requested {formatTimestamp(approval.created_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={confidenceVariant(approval.confidence)}>
{approval.confidence}% confidence
</Badge>
<Badge variant={statusBadgeVariant(approval.status)}>
{approval.status}
</Badge>
</div>
</div>
{approval.payload || approval.rubric_scores ? (
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted">
<summary className="cursor-pointer font-semibold text-strong">
Details
</summary>
{approval.payload ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
Payload: {JSON.stringify(approval.payload, null, 2)}
</pre>
) : null}
{approval.rubric_scores ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
Rubric: {JSON.stringify(approval.rubric_scores, null, 2)}
</pre>
) : null}
</details>
) : null}
{approval.status === "pending" ? (
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
size="sm"
onClick={() => handleDecision(approval.id, "approved")}
disabled={updatingId === approval.id}
<div className="space-y-6">
{sortedApprovals.pending.length > 0 ? (
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
Pending
</p>
{sortedApprovals.pending.map((approval) => {
const summary = approvalSummary(approval);
return (
<div
key={approval.id}
className="space-y-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDecision(approval.id, "rejected")}
disabled={updatingId === approval.id}
className={cn(
"border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]"
)}
>
Reject
</Button>
</div>
) : null}
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-strong">
{humanizeAction(approval.action_type)}
</p>
<p className="text-xs text-muted">
Requested {formatTimestamp(approval.created_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={confidenceVariant(approval.confidence)}>
{approval.confidence}% confidence
</Badge>
<Badge variant={statusBadgeVariant(approval.status)}>
{approval.status}
</Badge>
</div>
</div>
{summary.rows.length > 0 ? (
<div className="grid gap-2 text-sm text-strong sm:grid-cols-2">
{summary.rows.map((row) => (
<div key={`${approval.id}-${row.label}`}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
{row.label}
</p>
<p className="mt-1 text-sm text-strong">
{row.value}
</p>
</div>
))}
</div>
) : null}
{summary.reason ? (
<p className="text-sm text-muted">{summary.reason}</p>
) : null}
{approval.payload || approval.rubric_scores ? (
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted">
<summary className="cursor-pointer font-semibold text-strong">
Details
</summary>
{approval.payload ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
Payload: {JSON.stringify(approval.payload, null, 2)}
</pre>
) : null}
{approval.rubric_scores ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
Rubric:{" "}
{JSON.stringify(approval.rubric_scores, null, 2)}
</pre>
) : null}
</details>
) : null}
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
size="sm"
onClick={() => handleDecision(approval.id, "approved")}
disabled={updatingId === approval.id}
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDecision(approval.id, "rejected")}
disabled={updatingId === approval.id}
className={cn(
"border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]"
)}
>
Reject
</Button>
</div>
</div>
);
})}
</div>
))}
) : null}
{sortedApprovals.resolved.length > 0 ? (
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
Resolved
</p>
{sortedApprovals.resolved.map((approval) => {
const summary = approvalSummary(approval);
return (
<div
key={approval.id}
className="space-y-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-strong">
{humanizeAction(approval.action_type)}
</p>
<p className="text-xs text-muted">
Requested {formatTimestamp(approval.created_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={confidenceVariant(approval.confidence)}>
{approval.confidence}% confidence
</Badge>
<Badge variant={statusBadgeVariant(approval.status)}>
{approval.status}
</Badge>
</div>
</div>
{summary.rows.length > 0 ? (
<div className="grid gap-2 text-sm text-strong sm:grid-cols-2">
{summary.rows.map((row) => (
<div key={`${approval.id}-${row.label}`}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
{row.label}
</p>
<p className="mt-1 text-sm text-strong">
{row.value}
</p>
</div>
))}
</div>
) : null}
{summary.reason ? (
<p className="text-sm text-muted">{summary.reason}</p>
) : null}
{approval.payload || approval.rubric_scores ? (
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted">
<summary className="cursor-pointer font-semibold text-strong">
Details
</summary>
{approval.payload ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
Payload: {JSON.stringify(approval.payload, null, 2)}
</pre>
) : null}
{approval.rubric_scores ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
Rubric:{" "}
{JSON.stringify(approval.rubric_scores, null, 2)}
</pre>
) : null}
</details>
) : null}
</div>
);
})}
</div>
) : null}
</div>
)}
</CardContent>

View File

@@ -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<HTMLDivElement>) => void;
onDragEnd?: (event: React.DragEvent<HTMLDivElement>) => 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 (
<div
className="group cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md"
className={cn(
"group cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md",
isDragging && "opacity-60 shadow-none",
)}
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={onClick}
role="button"
tabIndex={0}
@@ -81,18 +63,16 @@ export function TaskCard({
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<span
className={cn(
"inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wide",
config.badge,
config.text,
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", config.dot)} />
{config.label}
</span>
<p className="text-sm font-medium text-slate-900">{title}</p>
</div>
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
priorityBadge(priority) ?? "bg-slate-100 text-slate-600",
)}
>
{priorityLabel}
</span>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center gap-2">

View File

@@ -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<string | null>(null);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
const grouped = useMemo(() => {
const buckets: Record<string, Task[]> = {};
for (const column of columns) {
@@ -82,12 +91,67 @@ export function TaskBoard({
return buckets;
}, [tasks]);
const handleDragStart =
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
event.preventDefault();
if (activeColumn !== status) {
setActiveColumn(status);
}
};
const handleDragLeave =
(status: string) => (_event: React.DragEvent<HTMLDivElement>) => {
if (activeColumn === status) {
setActiveColumn(null);
}
};
return (
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6">
{columns.map((column) => {
const columnTasks = grouped[column.status] ?? [];
return (
<div key={column.title} className="kanban-column min-h-[calc(100vh-260px)]">
<div
key={column.title}
className={cn(
"kanban-column min-h-[calc(100vh-260px)]",
activeColumn === column.status && "ring-2 ring-slate-200",
)}
onDrop={handleDrop(column.status)}
onDragOver={handleDragOver(column.status)}
onDragLeave={handleDragLeave(column.status)}
>
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -96,37 +160,30 @@ export function TaskBoard({
{column.title}
</h3>
</div>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
column.badge,
)}
>
{columnTasks.length}
</span>
</div>
</div>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
{column.status === "inbox" ? (
<button
type="button"
onClick={onCreateTask}
disabled={isCreateDisabled}
className={cn(
"group mb-3 flex w-full items-center justify-center rounded-lg border-2 border-dashed border-slate-300 px-4 py-4 text-sm font-medium transition",
column.accent,
isCreateDisabled && "cursor-not-allowed opacity-60"
)}
>
<div className={cn("flex items-center gap-2", column.text)}>
<span className="text-sm font-medium">New task</span>
</div>
</button>
) : null}
<div className="space-y-3">
{columnTasks.map((task) => (
<TaskCard
key={task.id}
title={task.title}
status={column.status}
priority={task.priority}
assignee={task.assignee}
due={formatDueDate(task.due_at)}
onClick={() => onTaskSelect?.(task)}
draggable
isDragging={draggingId === task.id}
onDragStart={handleDragStart(task)}
onDragEnd={handleDragEnd}
/>
))}
</div>

View File

@@ -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 autocreate 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 humanlike name to give it personality.
Agent create (leadallowed):
POST $BASE_URL/api/v1/agent/agents
Body example: