feat: implement task creation endpoint for board leads and enhance board chat functionality
This commit is contained in:
@@ -21,13 +21,20 @@ from app.integrations.openclaw_gateway import (
|
||||
send_message,
|
||||
)
|
||||
from app.models.agents import Agent
|
||||
from app.models.tasks import Task
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.schemas.approvals import ApprovalCreate, ApprovalRead
|
||||
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
|
||||
from app.schemas.board_onboarding import BoardOnboardingRead
|
||||
from app.schemas.boards import BoardRead
|
||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskRead, TaskUpdate
|
||||
from app.schemas.tasks import (
|
||||
TaskCommentCreate,
|
||||
TaskCommentRead,
|
||||
TaskCreate,
|
||||
TaskRead,
|
||||
TaskUpdate,
|
||||
)
|
||||
from app.schemas.agents import AgentCreate, AgentHeartbeatCreate, AgentNudge, AgentRead
|
||||
from app.services.activity_log import record_activity
|
||||
|
||||
@@ -120,6 +127,55 @@ def list_tasks(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/tasks", response_model=TaskRead)
|
||||
def create_task(
|
||||
payload: TaskCreate,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
) -> TaskRead:
|
||||
_guard_board_access(agent_ctx, board)
|
||||
if not agent_ctx.agent.is_board_lead:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
tasks_api.validate_task_status(payload.status)
|
||||
task = Task.model_validate(payload)
|
||||
task.board_id = board.id
|
||||
task.auto_created = True
|
||||
task.auto_reason = f"lead_agent:{agent_ctx.agent.id}"
|
||||
if task.assigned_agent_id:
|
||||
agent = session.get(Agent, task.assigned_agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if agent.is_board_lead:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Board leads cannot assign tasks to themselves.",
|
||||
)
|
||||
if agent.board_id and agent.board_id != board.id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
session.add(task)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
record_activity(
|
||||
session,
|
||||
event_type="task.created",
|
||||
task_id=task.id,
|
||||
message=f"Task created by lead: {task.title}.",
|
||||
agent_id=agent_ctx.agent.id,
|
||||
)
|
||||
session.commit()
|
||||
if task.assigned_agent_id:
|
||||
assigned_agent = session.get(Agent, task.assigned_agent_id)
|
||||
if assigned_agent:
|
||||
tasks_api._notify_agent_on_task_assign(
|
||||
session=session,
|
||||
board=board,
|
||||
task=task,
|
||||
agent=assigned_agent,
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/boards/{board_id}/tasks/{task_id}", response_model=TaskRead)
|
||||
def update_task(
|
||||
payload: TaskUpdate,
|
||||
|
||||
@@ -1,15 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from datetime import datetime, timezone
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
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_or_agent
|
||||
from app.db.session import get_session
|
||||
from app.core.config import settings
|
||||
from app.db.session import engine, get_session
|
||||
from app.integrations.openclaw_gateway import (
|
||||
GatewayConfig as GatewayClientConfig,
|
||||
OpenClawGatewayError,
|
||||
ensure_session,
|
||||
send_message,
|
||||
)
|
||||
from app.models.agents import Agent
|
||||
from app.models.board_memory import BoardMemory
|
||||
from app.models.gateways import Gateway
|
||||
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
|
||||
|
||||
router = APIRouter(prefix="/boards/{board_id}/memory", tags=["board-memory"])
|
||||
|
||||
MENTION_PATTERN = re.compile(r"@([A-Za-z][\w-]{0,31})")
|
||||
|
||||
|
||||
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 _serialize_memory(memory: BoardMemory) -> dict[str, object]:
|
||||
return BoardMemoryRead.model_validate(
|
||||
memory, from_attributes=True
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
def _extract_mentions(message: str) -> set[str]:
|
||||
return {match.group(1).lower() for match in MENTION_PATTERN.finditer(message)}
|
||||
|
||||
|
||||
def _matches_mention(agent: Agent, mentions: set[str]) -> bool:
|
||||
if not mentions:
|
||||
return False
|
||||
name = (agent.name or "").strip()
|
||||
if not name:
|
||||
return False
|
||||
normalized = name.lower()
|
||||
if normalized in mentions:
|
||||
return True
|
||||
first = normalized.split()[0]
|
||||
return first in mentions
|
||||
|
||||
|
||||
def _gateway_config(session: Session, board) -> GatewayClientConfig | None:
|
||||
if not board.gateway_id:
|
||||
return None
|
||||
gateway = session.get(Gateway, board.gateway_id)
|
||||
if gateway is None or not gateway.url:
|
||||
return None
|
||||
return GatewayClientConfig(url=gateway.url, token=gateway.token)
|
||||
|
||||
|
||||
async def _send_agent_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 _fetch_memory_events(
|
||||
board_id,
|
||||
since: datetime,
|
||||
) -> list[BoardMemory]:
|
||||
with Session(engine) as session:
|
||||
statement = (
|
||||
select(BoardMemory)
|
||||
.where(col(BoardMemory.board_id) == board_id)
|
||||
.where(col(BoardMemory.created_at) >= since)
|
||||
.order_by(col(BoardMemory.created_at))
|
||||
)
|
||||
return list(session.exec(statement))
|
||||
|
||||
|
||||
def _notify_chat_targets(
|
||||
*,
|
||||
session: Session,
|
||||
board,
|
||||
memory: BoardMemory,
|
||||
actor: ActorContext,
|
||||
) -> None:
|
||||
if not memory.content:
|
||||
return
|
||||
config = _gateway_config(session, board)
|
||||
if config is None:
|
||||
return
|
||||
mentions = _extract_mentions(memory.content)
|
||||
statement = select(Agent).where(col(Agent.board_id) == board.id)
|
||||
targets: dict[str, Agent] = {}
|
||||
for agent in session.exec(statement):
|
||||
if agent.is_board_lead:
|
||||
targets[str(agent.id)] = agent
|
||||
continue
|
||||
if mentions and _matches_mention(agent, mentions):
|
||||
targets[str(agent.id)] = agent
|
||||
if actor.actor_type == "agent" and actor.agent:
|
||||
targets.pop(str(actor.agent.id), None)
|
||||
if not targets:
|
||||
return
|
||||
actor_name = "User"
|
||||
if actor.actor_type == "agent" and actor.agent:
|
||||
actor_name = actor.agent.name
|
||||
elif actor.user:
|
||||
actor_name = actor.user.preferred_name or actor.user.name or actor_name
|
||||
snippet = memory.content.strip()
|
||||
if len(snippet) > 800:
|
||||
snippet = f"{snippet[:797]}..."
|
||||
base_url = settings.base_url or "http://localhost:8000"
|
||||
for agent in targets.values():
|
||||
if not agent.openclaw_session_id:
|
||||
continue
|
||||
mentioned = _matches_mention(agent, mentions)
|
||||
header = "BOARD CHAT MENTION" if mentioned else "BOARD CHAT"
|
||||
message = (
|
||||
f"{header}\n"
|
||||
f"Board: {board.name}\n"
|
||||
f"From: {actor_name}\n\n"
|
||||
f"{snippet}\n\n"
|
||||
"Reply via board chat:\n"
|
||||
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
|
||||
'Body: {"content":"...","tags":["chat"]}'
|
||||
)
|
||||
try:
|
||||
asyncio.run(
|
||||
_send_agent_message(
|
||||
session_key=agent.openclaw_session_id,
|
||||
config=config,
|
||||
agent_name=agent.name,
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
except OpenClawGatewayError:
|
||||
continue
|
||||
|
||||
@router.get("", response_model=list[BoardMemoryRead])
|
||||
def list_board_memory(
|
||||
@@ -32,6 +183,37 @@ def list_board_memory(
|
||||
return list(session.exec(statement))
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream_board_memory(
|
||||
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
|
||||
memories = await run_in_threadpool(
|
||||
_fetch_memory_events, board.id, last_seen
|
||||
)
|
||||
for memory in memories:
|
||||
if memory.created_at > last_seen:
|
||||
last_seen = memory.created_at
|
||||
payload = {"memory": _serialize_memory(memory)}
|
||||
yield {"event": "memory", "data": json.dumps(payload)}
|
||||
await asyncio.sleep(2)
|
||||
|
||||
return EventSourceResponse(event_generator(), ping=15)
|
||||
|
||||
|
||||
@router.post("", response_model=BoardMemoryRead)
|
||||
def create_board_memory(
|
||||
payload: BoardMemoryCreate,
|
||||
@@ -42,13 +224,22 @@ def create_board_memory(
|
||||
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)
|
||||
is_chat = payload.tags is not None and "chat" in payload.tags
|
||||
source = payload.source
|
||||
if is_chat and not source:
|
||||
if actor.actor_type == "agent" and actor.agent:
|
||||
source = actor.agent.name
|
||||
elif actor.user:
|
||||
source = actor.user.preferred_name or actor.user.name or "User"
|
||||
memory = BoardMemory(
|
||||
board_id=board.id,
|
||||
content=payload.content,
|
||||
tags=payload.tags,
|
||||
source=payload.source,
|
||||
source=source,
|
||||
)
|
||||
session.add(memory)
|
||||
session.commit()
|
||||
session.refresh(memory)
|
||||
if is_chat:
|
||||
_notify_chat_targets(session=session, board=board, memory=memory, actor=actor)
|
||||
return memory
|
||||
|
||||
@@ -140,6 +140,12 @@ def _lead_was_mentioned(
|
||||
return False
|
||||
|
||||
|
||||
def _lead_created_task(task: Task, lead: Agent) -> bool:
|
||||
if not task.auto_created or not task.auto_reason:
|
||||
return False
|
||||
return task.auto_reason == f"lead_agent:{lead.id}"
|
||||
|
||||
|
||||
def _fetch_task_events(
|
||||
board_id: UUID,
|
||||
since: datetime,
|
||||
@@ -692,11 +698,13 @@ def create_task_comment(
|
||||
) -> ActivityEvent:
|
||||
if actor.actor_type == "agent" and actor.agent:
|
||||
if actor.agent.is_board_lead and task.status != "review":
|
||||
if not _lead_was_mentioned(session, task, actor.agent):
|
||||
if not _lead_was_mentioned(session, task, actor.agent) and not _lead_created_task(
|
||||
task, actor.agent
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Board leads can only comment during review or when mentioned."
|
||||
"Board leads can only comment during review, when mentioned, or on tasks they created."
|
||||
),
|
||||
)
|
||||
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
|
||||
@@ -714,15 +722,15 @@ def create_task_comment(
|
||||
session.refresh(event)
|
||||
mention_names = _extract_mentions(payload.message)
|
||||
targets: dict[UUID, Agent] = {}
|
||||
if task.assigned_agent_id:
|
||||
assigned_agent = session.get(Agent, task.assigned_agent_id)
|
||||
if assigned_agent:
|
||||
targets[assigned_agent.id] = assigned_agent
|
||||
if mention_names and task.board_id:
|
||||
statement = select(Agent).where(col(Agent.board_id) == task.board_id)
|
||||
for agent in session.exec(statement):
|
||||
if _matches_mention(agent, mention_names):
|
||||
targets[agent.id] = agent
|
||||
if not mention_names and task.assigned_agent_id:
|
||||
assigned_agent = session.get(Agent, task.assigned_agent_id)
|
||||
if assigned_agent:
|
||||
targets[assigned_agent.id] = assigned_agent
|
||||
if actor.actor_type == "agent" and actor.agent:
|
||||
targets.pop(actor.agent.id, None)
|
||||
if targets:
|
||||
|
||||
@@ -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, Settings, X } from "lucide-react";
|
||||
import { MessageSquare, Pencil, Settings, X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
|
||||
@@ -53,6 +53,8 @@ type Task = {
|
||||
assigned_agent_id?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
approvalsCount?: number;
|
||||
approvalsPendingCount?: number;
|
||||
};
|
||||
|
||||
type Agent = {
|
||||
@@ -87,8 +89,25 @@ type Approval = {
|
||||
resolved_at?: string | null;
|
||||
};
|
||||
|
||||
type BoardChatMessage = {
|
||||
id: string;
|
||||
content: string;
|
||||
tags?: string[] | null;
|
||||
source?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const apiBase = getApiBaseUrl();
|
||||
|
||||
const approvalTaskId = (approval: Approval) => {
|
||||
const payload = approval.payload ?? {};
|
||||
return (
|
||||
(payload as Record<string, unknown>).task_id ??
|
||||
(payload as Record<string, unknown>).taskId ??
|
||||
(payload as Record<string, unknown>).taskID
|
||||
);
|
||||
};
|
||||
|
||||
const priorities = [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
@@ -147,6 +166,12 @@ export default function BoardDetailPage() {
|
||||
const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isChatOpen, setIsChatOpen] = useState(false);
|
||||
const [chatMessages, setChatMessages] = useState<BoardChatMessage[]>([]);
|
||||
const [chatInput, setChatInput] = useState("");
|
||||
const [isChatSending, setIsChatSending] = useState(false);
|
||||
const [chatError, setChatError] = useState<string | null>(null);
|
||||
const chatMessagesRef = useRef<BoardChatMessage[]>([]);
|
||||
const [isDeletingTask, setIsDeletingTask] = useState(false);
|
||||
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"board" | "list">("board");
|
||||
@@ -274,6 +299,10 @@ export default function BoardDetailPage() {
|
||||
agentsRef.current = agents;
|
||||
}, [agents]);
|
||||
|
||||
useEffect(() => {
|
||||
chatMessagesRef.current = chatMessages;
|
||||
}, [chatMessages]);
|
||||
|
||||
const loadApprovals = useCallback(async () => {
|
||||
if (!isSignedIn || !boardId) return;
|
||||
setIsApprovalsLoading(true);
|
||||
@@ -306,6 +335,138 @@ export default function BoardDetailPage() {
|
||||
loadApprovals();
|
||||
}, [boardId, isSignedIn, loadApprovals]);
|
||||
|
||||
const loadBoardChat = useCallback(async () => {
|
||||
if (!isSignedIn || !boardId) return;
|
||||
setChatError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(
|
||||
`${apiBase}/api/v1/boards/${boardId}/memory?limit=200`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to load board chat.");
|
||||
}
|
||||
const data = (await response.json()) as BoardChatMessage[];
|
||||
const chatOnly = data.filter((item) => item.tags?.includes("chat"));
|
||||
const ordered = chatOnly.sort((a, b) => {
|
||||
const aTime = new Date(a.created_at).getTime();
|
||||
const bTime = new Date(b.created_at).getTime();
|
||||
return aTime - bTime;
|
||||
});
|
||||
setChatMessages(ordered);
|
||||
} catch (err) {
|
||||
setChatError(
|
||||
err instanceof Error ? err.message : "Unable to load board chat.",
|
||||
);
|
||||
}
|
||||
}, [boardId, getToken, isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
loadBoardChat();
|
||||
}, [boardId, isSignedIn, loadBoardChat]);
|
||||
|
||||
const latestChatTimestamp = (items: BoardChatMessage[]) => {
|
||||
if (!items.length) return undefined;
|
||||
const latest = items.reduce((max, item) => {
|
||||
const ts = new Date(item.created_at).getTime();
|
||||
return Number.isNaN(ts) ? max : Math.max(max, ts);
|
||||
}, 0);
|
||||
if (!latest) return undefined;
|
||||
return new Date(latest).toISOString();
|
||||
};
|
||||
|
||||
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}/memory/stream`,
|
||||
);
|
||||
const since = latestChatTimestamp(chatMessagesRef.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 board chat 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 === "memory" && data) {
|
||||
try {
|
||||
const payload = JSON.parse(data) as { memory?: BoardChatMessage };
|
||||
if (payload.memory?.tags?.includes("chat")) {
|
||||
setChatMessages((prev) => {
|
||||
const exists = prev.some(
|
||||
(item) => item.id === payload.memory?.id,
|
||||
);
|
||||
if (exists) return prev;
|
||||
const next = [...prev, payload.memory as BoardChatMessage];
|
||||
next.sort((a, b) => {
|
||||
const aTime = new Date(a.created_at).getTime();
|
||||
const bTime = new Date(b.created_at).getTime();
|
||||
return aTime - bTime;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
boundary = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setTimeout(connect, 3000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
abortController.abort();
|
||||
};
|
||||
}, [boardId, getToken, isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn || !boardId) return;
|
||||
let isCancelled = false;
|
||||
@@ -642,6 +803,55 @@ export default function BoardDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendChat = async () => {
|
||||
if (!isSignedIn || !boardId) return;
|
||||
const trimmed = chatInput.trim();
|
||||
if (!trimmed) return;
|
||||
setIsChatSending(true);
|
||||
setChatError(null);
|
||||
try {
|
||||
const token = await getToken();
|
||||
const response = await fetch(
|
||||
`${apiBase}/api/v1/boards/${boardId}/memory`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: trimmed,
|
||||
tags: ["chat"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to send message.");
|
||||
}
|
||||
const created = (await response.json()) as BoardChatMessage;
|
||||
if (created.tags?.includes("chat")) {
|
||||
setChatMessages((prev) => {
|
||||
const exists = prev.some((item) => item.id === created.id);
|
||||
if (exists) return prev;
|
||||
const next = [...prev, created];
|
||||
next.sort((a, b) => {
|
||||
const aTime = new Date(a.created_at).getTime();
|
||||
const bTime = new Date(b.created_at).getTime();
|
||||
return aTime - bTime;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setChatInput("");
|
||||
} catch (err) {
|
||||
setChatError(
|
||||
err instanceof Error ? err.message : "Unable to send message.",
|
||||
);
|
||||
} finally {
|
||||
setIsChatSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const assigneeById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
agents
|
||||
@@ -652,6 +862,28 @@ export default function BoardDetailPage() {
|
||||
return map;
|
||||
}, [agents, boardId]);
|
||||
|
||||
const pendingApprovalsByTaskId = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
approvals
|
||||
.filter((approval) => approval.status === "pending")
|
||||
.forEach((approval) => {
|
||||
const taskId = approvalTaskId(approval);
|
||||
if (!taskId || typeof taskId !== "string") return;
|
||||
map.set(taskId, (map.get(taskId) ?? 0) + 1);
|
||||
});
|
||||
return map;
|
||||
}, [approvals]);
|
||||
|
||||
const totalApprovalsByTaskId = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
approvals.forEach((approval) => {
|
||||
const taskId = approvalTaskId(approval);
|
||||
if (!taskId || typeof taskId !== "string") return;
|
||||
map.set(taskId, (map.get(taskId) ?? 0) + 1);
|
||||
});
|
||||
return map;
|
||||
}, [approvals]);
|
||||
|
||||
const displayTasks = useMemo(
|
||||
() =>
|
||||
tasks.map((task) => ({
|
||||
@@ -659,8 +891,10 @@ export default function BoardDetailPage() {
|
||||
assignee: task.assigned_agent_id
|
||||
? assigneeById.get(task.assigned_agent_id)
|
||||
: undefined,
|
||||
approvalsCount: totalApprovalsByTaskId.get(task.id) ?? 0,
|
||||
approvalsPendingCount: pendingApprovalsByTaskId.get(task.id) ?? 0,
|
||||
})),
|
||||
[tasks, assigneeById],
|
||||
[tasks, assigneeById, pendingApprovalsByTaskId, totalApprovalsByTaskId],
|
||||
);
|
||||
|
||||
const boardAgents = useMemo(
|
||||
@@ -712,11 +946,7 @@ export default function BoardDetailPage() {
|
||||
if (!selectedTask) return [];
|
||||
const taskId = selectedTask.id;
|
||||
return approvals.filter((approval) => {
|
||||
const payload = approval.payload ?? {};
|
||||
const payloadTaskId =
|
||||
(payload as Record<string, unknown>).task_id ??
|
||||
(payload as Record<string, unknown>).taskId ??
|
||||
(payload as Record<string, unknown>).taskID;
|
||||
const payloadTaskId = approvalTaskId(approval);
|
||||
return payloadTaskId === taskId;
|
||||
});
|
||||
}, [approvals, selectedTask]);
|
||||
@@ -770,6 +1000,7 @@ export default function BoardDetailPage() {
|
||||
};
|
||||
|
||||
const openComments = (task: Task) => {
|
||||
setIsChatOpen(false);
|
||||
setSelectedTask(task);
|
||||
setIsDetailOpen(true);
|
||||
void loadComments(task.id);
|
||||
@@ -785,6 +1016,18 @@ export default function BoardDetailPage() {
|
||||
setIsEditDialogOpen(false);
|
||||
};
|
||||
|
||||
const openBoardChat = () => {
|
||||
if (isDetailOpen) {
|
||||
closeComments();
|
||||
}
|
||||
setIsChatOpen(true);
|
||||
};
|
||||
|
||||
const closeBoardChat = () => {
|
||||
setIsChatOpen(false);
|
||||
setChatError(null);
|
||||
};
|
||||
|
||||
const handlePostComment = async () => {
|
||||
if (!selectedTask || !boardId || !isSignedIn) return;
|
||||
const trimmed = newComment.trim();
|
||||
@@ -1200,6 +1443,14 @@ export default function BoardDetailPage() {
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openBoardChat}
|
||||
className="gap-2"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Board chat
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/boards/${boardId}/edit`)}
|
||||
@@ -1349,6 +1600,12 @@ export default function BoardDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||
{task.approvalsPendingCount ? (
|
||||
<span className="inline-flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||
Approval needed · {task.approvalsPendingCount}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
||||
@@ -1387,8 +1644,17 @@ export default function BoardDetailPage() {
|
||||
</div>
|
||||
</main>
|
||||
</SignedIn>
|
||||
{isDetailOpen ? (
|
||||
<div className="fixed inset-0 z-40 bg-slate-900/20" onClick={closeComments} />
|
||||
{isDetailOpen || isChatOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-slate-900/20"
|
||||
onClick={() => {
|
||||
if (isChatOpen) {
|
||||
closeBoardChat();
|
||||
} else {
|
||||
closeComments();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -1622,7 +1888,10 @@ export default function BoardDetailPage() {
|
||||
</aside>
|
||||
|
||||
<Dialog open={isApprovalsOpen} onOpenChange={setIsApprovalsOpen}>
|
||||
<DialogContent aria-label="Approvals">
|
||||
<DialogContent
|
||||
aria-label="Approvals"
|
||||
className="flex h-[85vh] max-w-3xl flex-col overflow-hidden"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Approvals</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -1630,18 +1899,115 @@ export default function BoardDetailPage() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{boardId ? (
|
||||
<BoardApprovalsPanel
|
||||
boardId={boardId}
|
||||
approvals={approvals}
|
||||
isLoading={isApprovalsLoading}
|
||||
error={approvalsError}
|
||||
onDecision={handleApprovalDecision}
|
||||
onRefresh={loadApprovals}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<BoardApprovalsPanel
|
||||
boardId={boardId}
|
||||
approvals={approvals}
|
||||
isLoading={isApprovalsLoading}
|
||||
error={approvalsError}
|
||||
onDecision={handleApprovalDecision}
|
||||
onRefresh={loadApprovals}
|
||||
scrollable
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-50 h-full w-[560px] max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform",
|
||||
isChatOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Board chat
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
Talk to the lead agent. Tag others with @name.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeBoardChat}
|
||||
className="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-50"
|
||||
aria-label="Close board chat"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-hidden px-6 py-4">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto rounded-2xl border border-slate-200 bg-white p-4">
|
||||
{chatError ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{chatError}
|
||||
</div>
|
||||
) : null}
|
||||
{chatMessages.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
No messages yet. Start the conversation with your lead agent.
|
||||
</p>
|
||||
) : (
|
||||
chatMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{message.source ?? "User"}
|
||||
</p>
|
||||
<span className="text-xs text-slate-400">
|
||||
{formatTaskTimestamp(message.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-slate-900">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ ...props }) => (
|
||||
<p className="mb-2 last:mb-0" {...props} />
|
||||
),
|
||||
ul: ({ ...props }) => (
|
||||
<ul className="mb-2 list-disc pl-5" {...props} />
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol className="mb-2 list-decimal pl-5" {...props} />
|
||||
),
|
||||
strong: ({ ...props }) => (
|
||||
<strong className="font-semibold" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Textarea
|
||||
value={chatInput}
|
||||
onChange={(event) => setChatInput(event.target.value)}
|
||||
placeholder="Message the board lead. Tag agents with @name."
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSendChat}
|
||||
disabled={isChatSending || !chatInput.trim()}
|
||||
>
|
||||
{isChatSending ? "Sending…" : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent aria-label="Edit task">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -30,6 +30,7 @@ type BoardApprovalsPanelProps = {
|
||||
error?: string | null;
|
||||
onRefresh?: () => void;
|
||||
onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
|
||||
scrollable?: boolean;
|
||||
};
|
||||
|
||||
const formatTimestamp = (value?: string | null) => {
|
||||
@@ -108,12 +109,14 @@ export function BoardApprovalsPanel({
|
||||
error: externalError,
|
||||
onRefresh,
|
||||
onDecision,
|
||||
scrollable = false,
|
||||
}: BoardApprovalsPanelProps) {
|
||||
const { getToken, isSignedIn } = useAuth();
|
||||
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 [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const usingExternal = Array.isArray(externalApprovals);
|
||||
const approvals = usingExternal ? externalApprovals ?? [] : internalApprovals;
|
||||
const loadingState = usingExternal ? externalLoading ?? false : isLoading;
|
||||
@@ -204,8 +207,20 @@ export function BoardApprovalsPanel({
|
||||
return { pending, resolved };
|
||||
}, [approvals]);
|
||||
|
||||
const toggleExpanded = useCallback((approvalId: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(approvalId)) {
|
||||
next.delete(approvalId);
|
||||
} else {
|
||||
next.add(approvalId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className={scrollable ? "flex h-full flex-col" : undefined}>
|
||||
<CardHeader className="flex flex-col gap-4 border-b border-[color:var(--border)] pb-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -228,7 +243,12 @@ export function BoardApprovalsPanel({
|
||||
Review lead-agent decisions that require human approval.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-5">
|
||||
<CardContent
|
||||
className={cn(
|
||||
"space-y-4 pt-5",
|
||||
scrollable && "flex-1 overflow-y-auto"
|
||||
)}
|
||||
>
|
||||
{errorState ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{errorState}
|
||||
@@ -246,90 +266,106 @@ export function BoardApprovalsPanel({
|
||||
<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"
|
||||
>
|
||||
<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 className="divide-y divide-[color:var(--border)] rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)]">
|
||||
{sortedApprovals.pending.map((approval) => {
|
||||
const summary = approvalSummary(approval);
|
||||
const summaryLine = summary.rows
|
||||
.map((row) => `${row.label}: ${row.value}`)
|
||||
.join(" • ");
|
||||
const detailsPayload = JSON.stringify(
|
||||
{
|
||||
payload: approval.payload ?? null,
|
||||
rubric_scores: approval.rubric_scores ?? null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
const isExpanded = expandedIds.has(approval.id);
|
||||
return (
|
||||
<div key={approval.id} className="space-y-3 px-5 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-strong">
|
||||
{humanizeAction(approval.action_type)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<span>
|
||||
Requested {formatTimestamp(approval.created_at)}
|
||||
</span>
|
||||
{summaryLine ? (
|
||||
<>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span className="truncate">{summaryLine}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</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.reason ? (
|
||||
<p className="text-sm text-muted">{summary.reason}</p>
|
||||
) : null}
|
||||
{summary.rows.length > 0 ? (
|
||||
<dl className="grid gap-2 text-xs text-muted sm:grid-cols-2">
|
||||
{summary.rows.map((row) => (
|
||||
<div key={`${approval.id}-${row.label}`}>
|
||||
<dt className="font-semibold uppercase tracking-wide text-slate-500">
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-strong">
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted">
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold text-slate-700 hover:text-slate-900"
|
||||
onClick={() => toggleExpanded(approval.id)}
|
||||
>
|
||||
{isExpanded ? "Hide raw" : "View raw"}
|
||||
</button>
|
||||
<span>JSON payload + rubric</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<pre className="max-h-40 overflow-auto rounded-xl bg-slate-950 px-3 py-3 text-[11px] text-slate-100">
|
||||
{detailsPayload}
|
||||
</pre>
|
||||
) : 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>
|
||||
) : 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 ? (
|
||||
@@ -337,69 +373,85 @@ export function BoardApprovalsPanel({
|
||||
<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 className="divide-y divide-[color:var(--border)] rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)]">
|
||||
{sortedApprovals.resolved.map((approval) => {
|
||||
const summary = approvalSummary(approval);
|
||||
const summaryLine = summary.rows
|
||||
.map((row) => `${row.label}: ${row.value}`)
|
||||
.join(" • ");
|
||||
const detailsPayload = JSON.stringify(
|
||||
{
|
||||
payload: approval.payload ?? null,
|
||||
rubric_scores: approval.rubric_scores ?? null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
const isExpanded = expandedIds.has(approval.id);
|
||||
return (
|
||||
<div key={approval.id} className="space-y-3 px-5 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-strong">
|
||||
{humanizeAction(approval.action_type)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<span>
|
||||
Requested {formatTimestamp(approval.created_at)}
|
||||
</span>
|
||||
{summaryLine ? (
|
||||
<>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span className="truncate">{summaryLine}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
) : 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>
|
||||
);
|
||||
})}
|
||||
{summary.reason ? (
|
||||
<p className="text-sm text-muted">{summary.reason}</p>
|
||||
) : null}
|
||||
{summary.rows.length > 0 ? (
|
||||
<dl className="grid gap-2 text-xs text-muted sm:grid-cols-2">
|
||||
{summary.rows.map((row) => (
|
||||
<div key={`${approval.id}-${row.label}`}>
|
||||
<dt className="font-semibold uppercase tracking-wide text-slate-500">
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-strong">
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted">
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold text-slate-700 hover:text-slate-900"
|
||||
onClick={() => toggleExpanded(approval.id)}
|
||||
>
|
||||
{isExpanded ? "Hide raw" : "View raw"}
|
||||
</button>
|
||||
<span>JSON payload + rubric</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<pre className="max-h-40 overflow-auto rounded-xl bg-slate-950 px-3 py-3 text-[11px] text-slate-100">
|
||||
{detailsPayload}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ interface TaskCardProps {
|
||||
priority?: string;
|
||||
assignee?: string;
|
||||
due?: string;
|
||||
approvalsCount?: number;
|
||||
approvalsPendingCount?: number;
|
||||
onClick?: () => void;
|
||||
draggable?: boolean;
|
||||
isDragging?: boolean;
|
||||
@@ -19,12 +21,15 @@ export function TaskCard({
|
||||
priority,
|
||||
assignee,
|
||||
due,
|
||||
approvalsCount = 0,
|
||||
approvalsPendingCount = 0,
|
||||
onClick,
|
||||
draggable = false,
|
||||
isDragging = false,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: TaskCardProps) {
|
||||
const hasPendingApproval = approvalsPendingCount > 0;
|
||||
const priorityBadge = (value?: string) => {
|
||||
if (!value) return null;
|
||||
const normalized = value.toLowerCase();
|
||||
@@ -45,8 +50,9 @@ export function TaskCard({
|
||||
return (
|
||||
<div
|
||||
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",
|
||||
"group relative 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",
|
||||
hasPendingApproval && "border-amber-200 bg-amber-50/40",
|
||||
)}
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
@@ -61,18 +67,29 @@ export function TaskCard({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasPendingApproval ? (
|
||||
<span className="absolute left-0 top-0 h-full w-1 rounded-l-lg bg-amber-400" />
|
||||
) : null}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-slate-900">{title}</p>
|
||||
{hasPendingApproval ? (
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||
Approval needed · {approvalsPendingCount}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<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>
|
||||
<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">
|
||||
|
||||
@@ -14,6 +14,8 @@ type Task = {
|
||||
due_at?: string | null;
|
||||
assigned_agent_id?: string | null;
|
||||
assignee?: string;
|
||||
approvalsCount?: number;
|
||||
approvalsPendingCount?: number;
|
||||
};
|
||||
|
||||
type TaskBoardProps = {
|
||||
@@ -173,17 +175,19 @@ export function TaskBoard({
|
||||
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
||||
<div className="space-y-3">
|
||||
{columnTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
title={task.title}
|
||||
priority={task.priority}
|
||||
assignee={task.assignee}
|
||||
due={formatDueDate(task.due_at)}
|
||||
onClick={() => onTaskSelect?.(task)}
|
||||
draggable
|
||||
isDragging={draggingId === task.id}
|
||||
onDragStart={handleDragStart(task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
title={task.title}
|
||||
priority={task.priority}
|
||||
assignee={task.assignee}
|
||||
due={formatDueDate(task.due_at)}
|
||||
approvalsCount={task.approvalsCount}
|
||||
approvalsPendingCount={task.approvalsPendingCount}
|
||||
onClick={() => onTaskSelect?.(task)}
|
||||
draggable
|
||||
isDragging={draggingId === task.id}
|
||||
onDragStart={handleDragStart(task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -36,4 +36,5 @@ Write things down. Do not rely on short-term context.
|
||||
- Do not post task updates in chat/web channels under any circumstance.
|
||||
- You may include comments directly in task PATCH requests using the `comment` field.
|
||||
- Comments should be clear, well‑formatted markdown. Use headings, bullets, checklists, or tables when they improve clarity.
|
||||
- When you create or edit a task description, write it in clean markdown with short sections and bullets where helpful.
|
||||
- Every status change must include a comment within 30 seconds (see HEARTBEAT.md).
|
||||
|
||||
@@ -22,12 +22,19 @@ If any required input is missing, stop and request a provisioning update.
|
||||
- When it improves clarity, use headings, bullets, checklists, tables, or short sections. You do not need to use them for every comment.
|
||||
- Every status change must have a comment within 30 seconds.
|
||||
- Do not claim a new task if you already have one in progress.
|
||||
- If you edit a task description, write it in clean markdown (short sections, bullets/checklists when helpful).
|
||||
|
||||
## Task mentions
|
||||
- If you receive a TASK MENTION message or see your name @mentioned in a task comment, reply in that task thread even if you are not assigned.
|
||||
- Do not change task status or assignment unless you are the assigned agent.
|
||||
- Keep the reply focused on the mention request.
|
||||
|
||||
## Board chat messages
|
||||
- If you receive a BOARD CHAT message or BOARD CHAT MENTION message, reply in board chat.
|
||||
- Use: POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/memory
|
||||
Body: {"content":"...","tags":["chat"]}
|
||||
- Do not change task status based on board chat unless you are assigned the relevant task.
|
||||
|
||||
## Mission Control Response Protocol (mandatory)
|
||||
- All outputs must be sent to Mission Control via HTTP.
|
||||
- Always include: `X-Agent-Token: {{ auth_token }}`
|
||||
|
||||
@@ -19,7 +19,7 @@ If any required input is missing, stop and request a provisioning update.
|
||||
|
||||
## Non‑negotiable rules
|
||||
- The lead agent must **never** work a task directly.
|
||||
- Do **not** claim tasks. Do **not** post task comments **except** to leave review feedback.
|
||||
- Do **not** claim tasks. Do **not** post task comments **except** to leave review feedback, respond to a @mention, or add clarifying questions on tasks you created.
|
||||
- The lead only **delegates**, **requests approvals**, **updates board memory**, **nudges agents**, and **adds review feedback**.
|
||||
- All outputs must go to Mission Control via HTTP (never chat/web).
|
||||
- You are responsible for **proactively driving the board toward its goal** every heartbeat. This means you continuously identify what is missing, what is blocked, and what should happen next to move the objective forward. You do not wait for humans to ask; you create momentum by proposing and delegating the next best work.
|
||||
@@ -30,6 +30,12 @@ If any required input is missing, stop and request a provisioning update.
|
||||
- If you are @mentioned in a task comment, you may reply **regardless of task status**.
|
||||
- Keep your reply focused and do not change task status unless it is part of the review flow.
|
||||
|
||||
## Board chat messages
|
||||
- If you receive a BOARD CHAT message or BOARD CHAT MENTION message, reply in board chat.
|
||||
- Use: POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/memory
|
||||
Body: {"content":"...","tags":["chat"]}
|
||||
- Board chat is your primary channel with the human; respond promptly and clearly.
|
||||
|
||||
## Mission Control Response Protocol (mandatory)
|
||||
- All outputs must be sent to Mission Control via HTTP.
|
||||
- Always include: `X-Agent-Token: {{ auth_token }}`
|
||||
@@ -97,15 +103,21 @@ If any required input is missing, stop and request a provisioning update.
|
||||
}
|
||||
|
||||
7) Creating new tasks:
|
||||
- Leads cannot create tasks directly (admin‑only).
|
||||
- If a new task is needed, request approval:
|
||||
- Leads **can** create tasks directly when confidence >= 70 and the action is not risky/external.
|
||||
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks
|
||||
Body example:
|
||||
{"title":"...","description":"...","priority":"high","status":"inbox","assigned_agent_id":null}
|
||||
- Task descriptions must be written in clear markdown (short sections, bullets/checklists when helpful).
|
||||
- If confidence < 70 or the action is risky/external, request approval instead:
|
||||
POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/approvals
|
||||
Body example:
|
||||
{"action_type":"task.create","confidence":75,"payload":{"title":"...","description":"..."},"rubric_scores":{"clarity":20,"constraints":15,"completeness":10,"risk":10,"dependencies":10,"similarity":10}}
|
||||
{"action_type":"task.create","confidence":60,"payload":{"title":"...","description":"..."},"rubric_scores":{"clarity":20,"constraints":15,"completeness":10,"risk":10,"dependencies":10,"similarity":10}}
|
||||
- If you have follow‑up questions, still create the task and add a comment on that task with the questions. You are allowed to comment on tasks you created.
|
||||
|
||||
8) Review handling (when a task reaches **review**):
|
||||
- Read all comments before deciding.
|
||||
- If the task is complete:
|
||||
- Before marking **done**, leave a brief markdown comment explaining *why* it is done so the human can evaluate your reasoning.
|
||||
- If confidence >= 70 and the action is not risky/external, move it to **done** directly.
|
||||
PATCH $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}
|
||||
Body: {"status":"done"}
|
||||
@@ -158,7 +170,7 @@ curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=
|
||||
|
||||
## Common mistakes (avoid)
|
||||
- Claiming or working tasks as the lead.
|
||||
- Posting task comments.
|
||||
- Posting task comments outside review, @mentions, or tasks you created.
|
||||
- Assigning a task to yourself.
|
||||
- Marking tasks review/done (lead cannot).
|
||||
- Using non‑agent endpoints or Authorization header.
|
||||
|
||||
Reference in New Issue
Block a user