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), agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> AgentRead: ) -> AgentRead:
if agent_ctx.agent.name != payload.name: 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] return await agents_api.heartbeat_or_create_agent( # type: ignore[attr-defined]
payload=payload, payload=payload,
session=session, session=session,

View File

@@ -1,17 +1,21 @@
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
import asyncio
import json
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import update from sqlalchemy import asc, or_, update
from sqlmodel import Session, col, select 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.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.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext 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 GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.activity_events import ActivityEvent from app.models.activity_events import ActivityEvent
@@ -34,6 +38,22 @@ OFFLINE_AFTER = timedelta(minutes=10)
AGENT_SESSION_PREFIX = "agent" 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( def _normalize_identity_profile(
profile: dict[str, object] | None, profile: dict[str, object] | None,
) -> dict[str, str] | None: ) -> dict[str, str] | None:
@@ -172,6 +192,30 @@ def _with_computed_status(agent: Agent) -> Agent:
return 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: def _record_heartbeat(session: Session, agent: Agent) -> None:
record_activity( record_activity(
session, 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) @router.post("", response_model=AgentRead)
async def create_agent( async def create_agent(
payload: AgentCreate, payload: AgentCreate,

View File

@@ -1,12 +1,18 @@
from __future__ import annotations 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 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.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.models.approvals import Approval
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalUpdate 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"} 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]) @router.get("", response_model=list[ApprovalRead])
def list_approvals( def list_approvals(
status_filter: str | None = Query(default=None, alias="status"), status_filter: str | None = Query(default=None, alias="status"),
@@ -34,6 +83,38 @@ def list_approvals(
return list(session.exec(statement)) 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) @router.post("", response_model=ApprovalRead)
def create_approval( def create_approval(
payload: ApprovalCreate, payload: ApprovalCreate,

View File

@@ -9,7 +9,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sse_starlette.sse import EventSourceResponse from sse_starlette.sse import EventSourceResponse
from starlette.concurrency import run_in_threadpool 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 sqlmodel import Session, col, select
from app.api.deps import ( from app.api.deps import (
@@ -32,6 +32,7 @@ from app.models.agents import Agent
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.models.tasks import Task 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.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity 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) 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( def _notify_lead_on_task_create(
*, *,
session: Session, session: Session,
@@ -300,6 +368,15 @@ def create_task(
) )
session.commit() session.commit()
_notify_lead_on_task_create(session=session, board=board, task=task) _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 return task
@@ -311,6 +388,7 @@ def update_task(
actor: ActorContext = Depends(require_admin_or_agent), actor: ActorContext = Depends(require_admin_or_agent),
) -> Task: ) -> Task:
previous_status = task.status previous_status = task.status
previous_assigned = task.assigned_agent_id
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
comment = updates.pop("comment", None) comment = updates.pop("comment", None)
if comment is not None and not comment.strip(): 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, agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
) )
session.commit() 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 return task
@@ -440,6 +535,8 @@ def delete_task(
task: Task = Depends(get_task_or_404), task: Task = Depends(get_task_or_404),
auth: AuthContext = Depends(require_admin_auth), auth: AuthContext = Depends(require_admin_auth),
) -> dict[str, bool]: ) -> 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.delete(task)
session.commit() session.commit()
return {"ok": True} return {"ok": True}

View File

@@ -144,6 +144,9 @@ def _build_context(
context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field]) context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field])
for field, context_key in IDENTITY_PROFILE_FIELDS.items() 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 { return {
"agent_name": agent.name, "agent_name": agent.name,
"agent_id": agent_id, "agent_id": agent_id,
@@ -162,7 +165,7 @@ def _build_context(
"main_session_key": main_session_key, "main_session_key": main_session_key,
"workspace_root": workspace_root, "workspace_root": workspace_root,
"user_name": (user.name or "") if user else "", "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_pronouns": (user.pronouns or "") if user else "",
"user_timezone": (user.timezone or "") if user else "", "user_timezone": (user.timezone or "") if user else "",
"user_notes": (user.notes 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]) context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field])
for field, context_key in IDENTITY_PROFILE_FIELDS.items() 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 { return {
"agent_name": agent.name, "agent_name": agent.name,
"agent_id": str(agent.id), "agent_id": str(agent.id),
@@ -207,7 +213,7 @@ def _build_main_context(
"main_session_key": gateway.main_session_key or "", "main_session_key": gateway.main_session_key or "",
"workspace_root": gateway.workspace_root or "", "workspace_root": gateway.workspace_root or "",
"user_name": (user.name or "") if user else "", "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_pronouns": (user.pronouns or "") if user else "",
"user_timezone": (user.timezone or "") if user else "", "user_timezone": (user.timezone or "") if user else "",
"user_notes": (user.notes 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) await _patch_gateway_agent_list(agent_id, workspace_path, heartbeat, client_config)
context = _build_context(agent, board, gateway, auth_token, user) 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) existing_files = await _gateway_agent_files_index(agent_id, client_config)
include_bootstrap = True include_bootstrap = True
if action == "update" and not force_bootstrap: 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") raise OpenClawGatewayError("Unable to resolve gateway main agent id")
context = _build_main_context(agent, gateway, auth_token, user) 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) existing_files = await _gateway_agent_files_index(agent_id, client_config)
include_bootstrap = action != "update" or force_bootstrap include_bootstrap = action != "update" or force_bootstrap
if action == "update" and not 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 { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; 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 ReactMarkdown from "react-markdown";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel"; import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
@@ -61,6 +61,8 @@ type Agent = {
status: string; status: string;
board_id?: string | null; board_id?: string | null;
is_board_lead?: boolean; is_board_lead?: boolean;
updated_at?: string | null;
last_seen_at?: string | null;
identity_profile?: { identity_profile?: {
emoji?: string | null; emoji?: string | null;
} | null; } | null;
@@ -130,8 +132,11 @@ export default function BoardDetailPage() {
const [commentsError, setCommentsError] = useState<string | null>(null); const [commentsError, setCommentsError] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false);
const tasksRef = useRef<Task[]>([]); const tasksRef = useRef<Task[]>([]);
const approvalsRef = useRef<Approval[]>([]);
const agentsRef = useRef<Agent[]>([]);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isApprovalsOpen, setIsApprovalsOpen] = useState(false); const [isApprovalsOpen, setIsApprovalsOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [approvals, setApprovals] = useState<Approval[]>([]); const [approvals, setApprovals] = useState<Approval[]>([]);
const [isApprovalsLoading, setIsApprovalsLoading] = useState(false); const [isApprovalsLoading, setIsApprovalsLoading] = useState(false);
@@ -139,6 +144,9 @@ export default function BoardDetailPage() {
const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>( const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>(
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 [isDialogOpen, setIsDialogOpen] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@@ -173,6 +181,32 @@ export default function BoardDetailPage() {
return latestTime ? new Date(latestTime).toISOString() : null; 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 () => { const loadBoard = async () => {
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
setIsLoading(true); setIsLoading(true);
@@ -229,6 +263,14 @@ export default function BoardDetailPage() {
tasksRef.current = tasks; tasksRef.current = tasks;
}, [tasks]); }, [tasks]);
useEffect(() => {
approvalsRef.current = approvals;
}, [approvals]);
useEffect(() => {
agentsRef.current = agents;
}, [agents]);
const loadApprovals = useCallback(async () => { const loadApprovals = useCallback(async () => {
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
setIsApprovalsLoading(true); setIsApprovalsLoading(true);
@@ -259,11 +301,96 @@ export default function BoardDetailPage() {
useEffect(() => { useEffect(() => {
loadApprovals(); loadApprovals();
if (!isSignedIn || !boardId) return;
const interval = setInterval(loadApprovals, 15000);
return () => clearInterval(interval);
}, [boardId, isSignedIn, loadApprovals]); }, [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(() => { useEffect(() => {
if (!selectedTask) { if (!selectedTask) {
setEditTitle(""); setEditTitle("");
@@ -378,6 +505,93 @@ export default function BoardDetailPage() {
}; };
}, [board, boardId, getToken, isSignedIn, selectedTask?.id]); }, [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 = () => { const resetForm = () => {
setTitle(""); setTitle("");
setDescription(""); setDescription("");
@@ -622,6 +836,79 @@ export default function BoardDetailPage() {
setSaveTaskError(null); 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) => const agentInitials = (agent: Agent) =>
agent.name agent.name
.split(" ") .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) => { const formatApprovalTimestamp = (value?: string | null) => {
if (!value) return "—"; if (!value) return "—";
const date = new Date(value); 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( const handleApprovalDecision = useCallback(
async (approvalId: string, status: "approved" | "rejected") => { async (approvalId: string, status: "approved" | "rejected") => {
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
@@ -745,15 +1120,28 @@ export default function BoardDetailPage() {
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-1"> <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 Board
</button> </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 List
</button> </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> </div>
<Button onClick={() => setIsDialogOpen(true)}> <Button onClick={() => setIsDialogOpen(true)}>
New task New task
@@ -770,18 +1158,15 @@ export default function BoardDetailPage() {
</span> </span>
) : null} ) : null}
</Button> </Button>
<Button <button
variant="outline" type="button"
onClick={() => router.push(`/boards/${boardId}/edit`)} 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 <Settings className="h-4 w-4" />
</Button> </button>
<Button
variant="outline"
onClick={() => router.push("/boards")}
>
Back to boards
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -863,12 +1248,98 @@ export default function BoardDetailPage() {
Loading {titleLabel} Loading {titleLabel}
</div> </div>
) : ( ) : (
<TaskBoard <>
tasks={displayTasks} {viewMode === "board" ? (
onCreateTask={() => setIsDialogOpen(true)} <TaskBoard
isCreateDisabled={isCreating} tasks={displayTasks}
onTaskSelect={openComments} 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>
</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 className="flex flex-wrap items-start justify-between gap-2 text-xs text-slate-500">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500"> <p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
{approval.action_type.replace(/_/g, " ")} {humanizeApprovalAction(approval.action_type)}
</p> </p>
<p className="mt-1 text-xs text-slate-500"> <p className="mt-1 text-xs text-slate-500">
Requested {formatApprovalTimestamp(approval.created_at)} Requested {formatApprovalTimestamp(approval.created_at)}
@@ -966,10 +1437,24 @@ export default function BoardDetailPage() {
{approval.confidence}% confidence · {approval.status} {approval.confidence}% confidence · {approval.status}
</span> </span>
</div> </div>
{approval.payload ? ( {approvalRows(approval).length > 0 ? (
<pre className="mt-2 whitespace-pre-wrap text-xs text-slate-600"> <div className="mt-2 grid gap-2 text-xs text-slate-600 sm:grid-cols-2">
{JSON.stringify(approval.payload, null, 2)} {approvalRows(approval).map((row) => (
</pre> <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} ) : null}
{approval.status === "pending" ? ( {approval.status === "pending" ? (
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
@@ -1082,7 +1567,16 @@ export default function BoardDetailPage() {
Review pending decisions from your lead agent. Review pending decisions from your lead agent.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null} {boardId ? (
<BoardApprovalsPanel
boardId={boardId}
approvals={approvals}
isLoading={isApprovalsLoading}
error={approvalsError}
onDecision={handleApprovalDecision}
onRefresh={loadApprovals}
/>
) : null}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1198,6 +1692,14 @@ export default function BoardDetailPage() {
) : null} ) : null}
</div> </div>
<DialogFooter className="flex flex-wrap gap-2"> <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 <Button
variant="outline" variant="outline"
onClick={handleTaskReset} onClick={handleTaskReset}
@@ -1215,6 +1717,38 @@ export default function BoardDetailPage() {
</DialogContent> </DialogContent>
</Dialog> </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 <Dialog
open={isDialogOpen} open={isDialogOpen}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {

View File

@@ -25,6 +25,11 @@ type Approval = {
type BoardApprovalsPanelProps = { type BoardApprovalsPanelProps = {
boardId: string; boardId: string;
approvals?: Approval[];
isLoading?: boolean;
error?: string | null;
onRefresh?: () => void;
onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
}; };
const formatTimestamp = (value?: string | null) => { const formatTimestamp = (value?: string | null) => {
@@ -51,14 +56,71 @@ const confidenceVariant = (confidence: number) => {
return "warning"; 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 { getToken, isSignedIn } = useAuth();
const [approvals, setApprovals] = useState<Approval[]>([]); const [internalApprovals, setInternalApprovals] = useState<Approval[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [updatingId, setUpdatingId] = 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 () => { const loadApprovals = useCallback(async () => {
if (usingExternal) return;
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@@ -71,23 +133,29 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
}); });
if (!res.ok) throw new Error("Unable to load approvals."); if (!res.ok) throw new Error("Unable to load approvals.");
const data = (await res.json()) as Approval[]; const data = (await res.json()) as Approval[];
setApprovals(data); setInternalApprovals(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Unable to load approvals."); setError(err instanceof Error ? err.message : "Unable to load approvals.");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [boardId, getToken, isSignedIn]); }, [boardId, getToken, isSignedIn, usingExternal]);
useEffect(() => { useEffect(() => {
if (usingExternal) return;
loadApprovals(); loadApprovals();
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
const interval = setInterval(loadApprovals, 15000); const interval = setInterval(loadApprovals, 15000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [boardId, isSignedIn, loadApprovals]); }, [boardId, isSignedIn, loadApprovals, usingExternal]);
const handleDecision = useCallback( const handleDecision = useCallback(
async (approvalId: string, status: "approved" | "rejected") => { async (approvalId: string, status: "approved" | "rejected") => {
if (onDecision) {
onDecision(approvalId, status);
return;
}
if (usingExternal) return;
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
setUpdatingId(approvalId); setUpdatingId(approvalId);
setError(null); setError(null);
@@ -106,7 +174,7 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
); );
if (!res.ok) throw new Error("Unable to update approval."); if (!res.ok) throw new Error("Unable to update approval.");
const updated = (await res.json()) as Approval; const updated = (await res.json()) as Approval;
setApprovals((prev) => setInternalApprovals((prev) =>
prev.map((item) => (item.id === approvalId ? updated : item)) prev.map((item) => (item.id === approvalId ? updated : item))
); );
} catch (err) { } catch (err) {
@@ -117,19 +185,23 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
setUpdatingId(null); setUpdatingId(null);
} }
}, },
[boardId, getToken, isSignedIn] [boardId, getToken, isSignedIn, onDecision, usingExternal]
); );
const sortedApprovals = useMemo(() => { const sortedApprovals = useMemo(() => {
const pending = approvals.filter((item) => item.status === "pending");
const resolved = approvals.filter((item) => item.status !== "pending");
const sortByTime = (items: Approval[]) => const sortByTime = (items: Approval[]) =>
[...items].sort((a, b) => { [...items].sort((a, b) => {
const aTime = new Date(a.created_at).getTime(); const aTime = new Date(a.created_at).getTime();
const bTime = new Date(b.created_at).getTime(); const bTime = new Date(b.created_at).getTime();
return bTime - aTime; 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]); }, [approvals]);
return ( return (
@@ -141,10 +213,14 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
Approvals Approvals
</p> </p>
<p className="mt-1 text-lg font-semibold text-strong"> <p className="mt-1 text-lg font-semibold text-strong">
Pending decisions {sortedApprovals.pending.length} pending
</p> </p>
</div> </div>
<Button variant="secondary" size="sm" onClick={loadApprovals}> <Button
variant="secondary"
size="sm"
onClick={onRefresh ?? loadApprovals}
>
Refresh Refresh
</Button> </Button>
</div> </div>
@@ -153,82 +229,179 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 pt-5"> <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"> <div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error} {errorState}
</div> </div>
) : null} ) : null}
{isLoading ? ( {loadingState ? (
<p className="text-sm text-muted">Loading approvals</p> <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> <p className="text-sm text-muted">No approvals yet.</p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-6">
{sortedApprovals.map((approval) => ( {sortedApprovals.pending.length > 0 ? (
<div <div className="space-y-3">
key={approval.id} <p className="text-xs font-semibold uppercase tracking-wider text-muted">
className="space-y-2 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4" Pending
> </p>
<div className="flex flex-wrap items-start justify-between gap-2"> {sortedApprovals.pending.map((approval) => {
<div> const summary = approvalSummary(approval);
<p className="text-sm font-semibold text-strong"> return (
{approval.action_type.replace(/_/g, " ")} <div
</p> key={approval.id}
<p className="text-xs text-muted"> className="space-y-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
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}
> >
Approve <div className="flex flex-wrap items-start justify-between gap-2">
</Button> <div>
<Button <p className="text-sm font-semibold text-strong">
variant="outline" {humanizeAction(approval.action_type)}
size="sm" </p>
onClick={() => handleDecision(approval.id, "rejected")} <p className="text-xs text-muted">
disabled={updatingId === approval.id} Requested {formatTimestamp(approval.created_at)}
className={cn( </p>
"border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]" </div>
)} <div className="flex flex-wrap items-center gap-2">
> <Badge variant={confidenceVariant(approval.confidence)}>
Reject {approval.confidence}% confidence
</Button> </Badge>
</div> <Badge variant={statusBadgeVariant(approval.status)}>
) : null} {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> </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> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -4,71 +4,53 @@ import { cn } from "@/lib/utils";
interface TaskCardProps { interface TaskCardProps {
title: string; title: string;
status: string; priority?: string;
assignee?: string; assignee?: string;
due?: string; due?: string;
onClick?: () => void; onClick?: () => void;
draggable?: boolean;
isDragging?: boolean;
onDragStart?: (event: React.DragEvent<HTMLDivElement>) => void;
onDragEnd?: (event: React.DragEvent<HTMLDivElement>) => void;
} }
export function TaskCard({ export function TaskCard({
title, title,
status, priority,
assignee, assignee,
due, due,
onClick, onClick,
draggable = false,
isDragging = false,
onDragStart,
onDragEnd,
}: TaskCardProps) { }: TaskCardProps) {
const statusConfig: Record< const priorityBadge = (value?: string) => {
string, if (!value) return null;
{ label: string; dot: string; badge: string; text: string } const normalized = value.toLowerCase();
> = { if (normalized === "high") {
inbox: { return "bg-rose-100 text-rose-700";
label: "Inbox", }
dot: "bg-slate-400", if (normalized === "medium") {
badge: "bg-slate-100", return "bg-amber-100 text-amber-700";
text: "text-slate-600", }
}, if (normalized === "low") {
assigned: { return "bg-emerald-100 text-emerald-700";
label: "Assigned", }
dot: "bg-blue-500", return "bg-slate-100 text-slate-600";
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 config = statusConfig[status] ?? { const priorityLabel = priority ? priority.toUpperCase() : "MEDIUM";
label: status,
dot: "bg-slate-400",
badge: "bg-slate-100",
text: "text-slate-600",
};
return ( return (
<div <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} onClick={onClick}
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -81,18 +63,16 @@ export function TaskCard({
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="space-y-2"> <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> <p className="text-sm font-medium text-slate-900">{title}</p>
</div> </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>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500"> <div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { TaskCard } from "@/components/molecules/TaskCard"; import { TaskCard } from "@/components/molecules/TaskCard";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -21,6 +21,7 @@ type TaskBoardProps = {
onCreateTask: () => void; onCreateTask: () => void;
isCreateDisabled?: boolean; isCreateDisabled?: boolean;
onTaskSelect?: (task: Task) => void; onTaskSelect?: (task: Task) => void;
onTaskMove?: (taskId: string, status: string) => void;
}; };
const columns = [ const columns = [
@@ -30,6 +31,7 @@ const columns = [
dot: "bg-slate-400", dot: "bg-slate-400",
accent: "hover:border-slate-400 hover:bg-slate-50", accent: "hover:border-slate-400 hover:bg-slate-50",
text: "group-hover:text-slate-700 text-slate-500", text: "group-hover:text-slate-700 text-slate-500",
badge: "bg-slate-100 text-slate-600",
}, },
{ {
title: "In Progress", title: "In Progress",
@@ -37,6 +39,7 @@ const columns = [
dot: "bg-purple-500", dot: "bg-purple-500",
accent: "hover:border-purple-400 hover:bg-purple-50", accent: "hover:border-purple-400 hover:bg-purple-50",
text: "group-hover:text-purple-600 text-slate-500", text: "group-hover:text-purple-600 text-slate-500",
badge: "bg-purple-100 text-purple-700",
}, },
{ {
title: "Review", title: "Review",
@@ -44,6 +47,7 @@ const columns = [
dot: "bg-indigo-500", dot: "bg-indigo-500",
accent: "hover:border-indigo-400 hover:bg-indigo-50", accent: "hover:border-indigo-400 hover:bg-indigo-50",
text: "group-hover:text-indigo-600 text-slate-500", text: "group-hover:text-indigo-600 text-slate-500",
badge: "bg-indigo-100 text-indigo-700",
}, },
{ {
title: "Done", title: "Done",
@@ -51,6 +55,7 @@ const columns = [
dot: "bg-green-500", dot: "bg-green-500",
accent: "hover:border-green-400 hover:bg-green-50", accent: "hover:border-green-400 hover:bg-green-50",
text: "group-hover:text-green-600 text-slate-500", text: "group-hover:text-green-600 text-slate-500",
badge: "bg-emerald-100 text-emerald-700",
}, },
]; ];
@@ -69,7 +74,11 @@ export function TaskBoard({
onCreateTask, onCreateTask,
isCreateDisabled = false, isCreateDisabled = false,
onTaskSelect, onTaskSelect,
onTaskMove,
}: TaskBoardProps) { }: TaskBoardProps) {
const [draggingId, setDraggingId] = useState<string | null>(null);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
const grouped = useMemo(() => { const grouped = useMemo(() => {
const buckets: Record<string, Task[]> = {}; const buckets: Record<string, Task[]> = {};
for (const column of columns) { for (const column of columns) {
@@ -82,12 +91,67 @@ export function TaskBoard({
return buckets; return buckets;
}, [tasks]); }, [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 ( return (
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6"> <div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6">
{columns.map((column) => { {columns.map((column) => {
const columnTasks = grouped[column.status] ?? []; const columnTasks = grouped[column.status] ?? [];
return ( 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="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 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -96,37 +160,30 @@ export function TaskBoard({
{column.title} {column.title}
</h3> </h3>
</div> </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} {columnTasks.length}
</span> </span>
</div> </div>
</div> </div>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3"> <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"> <div className="space-y-3">
{columnTasks.map((task) => ( {columnTasks.map((task) => (
<TaskCard <TaskCard
key={task.id} key={task.id}
title={task.title} title={task.title}
status={column.status} priority={task.priority}
assignee={task.assignee} assignee={task.assignee}
due={formatDueDate(task.due_at)} due={formatDueDate(task.due_at)}
onClick={() => onTaskSelect?.(task)} onClick={() => onTaskSelect?.(task)}
draggable
isDragging={draggingId === task.id}
onDragStart={handleDragStart(task)}
onDragEnd={handleDragEnd}
/> />
))} ))}
</div> </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. - 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. - 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. - 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): Agent create (leadallowed):
POST $BASE_URL/api/v1/agent/agents POST $BASE_URL/api/v1/agent/agents
Body example: Body example: