feat: implement task creation endpoint for board leads and enhance board chat functionality

This commit is contained in:
Abhimanyu Saharan
2026-02-06 00:44:03 +05:30
parent f8f5849341
commit 69a6597936
10 changed files with 911 additions and 197 deletions

View File

@@ -21,13 +21,20 @@ from app.integrations.openclaw_gateway import (
send_message, send_message,
) )
from app.models.agents import Agent from app.models.agents import Agent
from app.models.tasks import Task
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.schemas.approvals import ApprovalCreate, ApprovalRead from app.schemas.approvals import ApprovalCreate, ApprovalRead
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.board_onboarding import BoardOnboardingRead from app.schemas.board_onboarding import BoardOnboardingRead
from app.schemas.boards import BoardRead 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.schemas.agents import AgentCreate, AgentHeartbeatCreate, AgentNudge, AgentRead
from app.services.activity_log import record_activity 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) @router.patch("/boards/{board_id}/tasks/{task_id}", response_model=TaskRead)
def update_task( def update_task(
payload: TaskUpdate, payload: TaskUpdate,

View File

@@ -1,15 +1,166 @@
from __future__ import annotations 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 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.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.board_memory import BoardMemory
from app.models.gateways import Gateway
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
router = APIRouter(prefix="/boards/{board_id}/memory", tags=["board-memory"]) 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]) @router.get("", response_model=list[BoardMemoryRead])
def list_board_memory( def list_board_memory(
@@ -32,6 +183,37 @@ def list_board_memory(
return list(session.exec(statement)) 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) @router.post("", response_model=BoardMemoryRead)
def create_board_memory( def create_board_memory(
payload: BoardMemoryCreate, payload: BoardMemoryCreate,
@@ -42,13 +224,22 @@ def create_board_memory(
if actor.actor_type == "agent" and actor.agent: if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id: if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) 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( memory = BoardMemory(
board_id=board.id, board_id=board.id,
content=payload.content, content=payload.content,
tags=payload.tags, tags=payload.tags,
source=payload.source, source=source,
) )
session.add(memory) session.add(memory)
session.commit() session.commit()
session.refresh(memory) session.refresh(memory)
if is_chat:
_notify_chat_targets(session=session, board=board, memory=memory, actor=actor)
return memory return memory

View File

@@ -140,6 +140,12 @@ def _lead_was_mentioned(
return False 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( def _fetch_task_events(
board_id: UUID, board_id: UUID,
since: datetime, since: datetime,
@@ -692,11 +698,13 @@ def create_task_comment(
) -> ActivityEvent: ) -> ActivityEvent:
if actor.actor_type == "agent" and actor.agent: if actor.actor_type == "agent" and actor.agent:
if actor.agent.is_board_lead and task.status != "review": 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=( 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: 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) session.refresh(event)
mention_names = _extract_mentions(payload.message) mention_names = _extract_mentions(payload.message)
targets: dict[UUID, Agent] = {} 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: if mention_names and task.board_id:
statement = select(Agent).where(col(Agent.board_id) == task.board_id) statement = select(Agent).where(col(Agent.board_id) == task.board_id)
for agent in session.exec(statement): for agent in session.exec(statement):
if _matches_mention(agent, mention_names): if _matches_mention(agent, mention_names):
targets[agent.id] = agent 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: if actor.actor_type == "agent" and actor.agent:
targets.pop(actor.agent.id, None) targets.pop(actor.agent.id, None)
if targets: if targets:

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, Settings, X } from "lucide-react"; import { MessageSquare, 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";
@@ -53,6 +53,8 @@ type Task = {
assigned_agent_id?: string | null; assigned_agent_id?: string | null;
created_at?: string | null; created_at?: string | null;
updated_at?: string | null; updated_at?: string | null;
approvalsCount?: number;
approvalsPendingCount?: number;
}; };
type Agent = { type Agent = {
@@ -87,8 +89,25 @@ type Approval = {
resolved_at?: string | null; resolved_at?: string | null;
}; };
type BoardChatMessage = {
id: string;
content: string;
tags?: string[] | null;
source?: string | null;
created_at: string;
};
const apiBase = getApiBaseUrl(); 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 = [ const priorities = [
{ value: "low", label: "Low" }, { value: "low", label: "Low" },
{ value: "medium", label: "Medium" }, { value: "medium", label: "Medium" },
@@ -147,6 +166,12 @@ export default function BoardDetailPage() {
const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>( const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>(
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 [isDeletingTask, setIsDeletingTask] = useState(false);
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null); const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [viewMode, setViewMode] = useState<"board" | "list">("board");
@@ -274,6 +299,10 @@ export default function BoardDetailPage() {
agentsRef.current = agents; agentsRef.current = agents;
}, [agents]); }, [agents]);
useEffect(() => {
chatMessagesRef.current = chatMessages;
}, [chatMessages]);
const loadApprovals = useCallback(async () => { const loadApprovals = useCallback(async () => {
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
setIsApprovalsLoading(true); setIsApprovalsLoading(true);
@@ -306,6 +335,138 @@ export default function BoardDetailPage() {
loadApprovals(); loadApprovals();
}, [boardId, isSignedIn, 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(() => { useEffect(() => {
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
let isCancelled = false; 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 assigneeById = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
agents agents
@@ -652,6 +862,28 @@ export default function BoardDetailPage() {
return map; return map;
}, [agents, boardId]); }, [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( const displayTasks = useMemo(
() => () =>
tasks.map((task) => ({ tasks.map((task) => ({
@@ -659,8 +891,10 @@ export default function BoardDetailPage() {
assignee: task.assigned_agent_id assignee: task.assigned_agent_id
? assigneeById.get(task.assigned_agent_id) ? assigneeById.get(task.assigned_agent_id)
: undefined, : undefined,
approvalsCount: totalApprovalsByTaskId.get(task.id) ?? 0,
approvalsPendingCount: pendingApprovalsByTaskId.get(task.id) ?? 0,
})), })),
[tasks, assigneeById], [tasks, assigneeById, pendingApprovalsByTaskId, totalApprovalsByTaskId],
); );
const boardAgents = useMemo( const boardAgents = useMemo(
@@ -712,11 +946,7 @@ export default function BoardDetailPage() {
if (!selectedTask) return []; if (!selectedTask) return [];
const taskId = selectedTask.id; const taskId = selectedTask.id;
return approvals.filter((approval) => { return approvals.filter((approval) => {
const payload = approval.payload ?? {}; const payloadTaskId = approvalTaskId(approval);
const payloadTaskId =
(payload as Record<string, unknown>).task_id ??
(payload as Record<string, unknown>).taskId ??
(payload as Record<string, unknown>).taskID;
return payloadTaskId === taskId; return payloadTaskId === taskId;
}); });
}, [approvals, selectedTask]); }, [approvals, selectedTask]);
@@ -770,6 +1000,7 @@ export default function BoardDetailPage() {
}; };
const openComments = (task: Task) => { const openComments = (task: Task) => {
setIsChatOpen(false);
setSelectedTask(task); setSelectedTask(task);
setIsDetailOpen(true); setIsDetailOpen(true);
void loadComments(task.id); void loadComments(task.id);
@@ -785,6 +1016,18 @@ export default function BoardDetailPage() {
setIsEditDialogOpen(false); setIsEditDialogOpen(false);
}; };
const openBoardChat = () => {
if (isDetailOpen) {
closeComments();
}
setIsChatOpen(true);
};
const closeBoardChat = () => {
setIsChatOpen(false);
setChatError(null);
};
const handlePostComment = async () => { const handlePostComment = async () => {
if (!selectedTask || !boardId || !isSignedIn) return; if (!selectedTask || !boardId || !isSignedIn) return;
const trimmed = newComment.trim(); const trimmed = newComment.trim();
@@ -1200,6 +1443,14 @@ export default function BoardDetailPage() {
</span> </span>
) : null} ) : null}
</Button> </Button>
<Button
variant="outline"
onClick={openBoardChat}
className="gap-2"
>
<MessageSquare className="h-4 w-4" />
Board chat
</Button>
<button <button
type="button" type="button"
onClick={() => router.push(`/boards/${boardId}/edit`)} onClick={() => router.push(`/boards/${boardId}/edit`)}
@@ -1349,6 +1600,12 @@ export default function BoardDetailPage() {
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500"> <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 <span
className={cn( className={cn(
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide", "rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
@@ -1387,8 +1644,17 @@ export default function BoardDetailPage() {
</div> </div>
</main> </main>
</SignedIn> </SignedIn>
{isDetailOpen ? ( {isDetailOpen || isChatOpen ? (
<div className="fixed inset-0 z-40 bg-slate-900/20" onClick={closeComments} /> <div
className="fixed inset-0 z-40 bg-slate-900/20"
onClick={() => {
if (isChatOpen) {
closeBoardChat();
} else {
closeComments();
}
}}
/>
) : null} ) : null}
<aside <aside
className={cn( className={cn(
@@ -1622,7 +1888,10 @@ export default function BoardDetailPage() {
</aside> </aside>
<Dialog open={isApprovalsOpen} onOpenChange={setIsApprovalsOpen}> <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> <DialogHeader>
<DialogTitle>Approvals</DialogTitle> <DialogTitle>Approvals</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -1630,18 +1899,115 @@ export default function BoardDetailPage() {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{boardId ? ( {boardId ? (
<BoardApprovalsPanel <div className="flex-1 overflow-hidden">
boardId={boardId} <BoardApprovalsPanel
approvals={approvals} boardId={boardId}
isLoading={isApprovalsLoading} approvals={approvals}
error={approvalsError} isLoading={isApprovalsLoading}
onDecision={handleApprovalDecision} error={approvalsError}
onRefresh={loadApprovals} onDecision={handleApprovalDecision}
/> onRefresh={loadApprovals}
scrollable
/>
</div>
) : null} ) : null}
</DialogContent> </DialogContent>
</Dialog> </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}> <Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent aria-label="Edit task"> <DialogContent aria-label="Edit task">
<DialogHeader> <DialogHeader>

View File

@@ -30,6 +30,7 @@ type BoardApprovalsPanelProps = {
error?: string | null; error?: string | null;
onRefresh?: () => void; onRefresh?: () => void;
onDecision?: (approvalId: string, status: "approved" | "rejected") => void; onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
scrollable?: boolean;
}; };
const formatTimestamp = (value?: string | null) => { const formatTimestamp = (value?: string | null) => {
@@ -108,12 +109,14 @@ export function BoardApprovalsPanel({
error: externalError, error: externalError,
onRefresh, onRefresh,
onDecision, onDecision,
scrollable = false,
}: BoardApprovalsPanelProps) { }: BoardApprovalsPanelProps) {
const { getToken, isSignedIn } = useAuth(); const { getToken, isSignedIn } = useAuth();
const [internalApprovals, setInternalApprovals] = 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 [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const usingExternal = Array.isArray(externalApprovals); const usingExternal = Array.isArray(externalApprovals);
const approvals = usingExternal ? externalApprovals ?? [] : internalApprovals; const approvals = usingExternal ? externalApprovals ?? [] : internalApprovals;
const loadingState = usingExternal ? externalLoading ?? false : isLoading; const loadingState = usingExternal ? externalLoading ?? false : isLoading;
@@ -204,8 +207,20 @@ export function BoardApprovalsPanel({
return { pending, resolved }; return { pending, resolved };
}, [approvals]); }, [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 ( 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"> <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 className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
@@ -228,7 +243,12 @@ export function BoardApprovalsPanel({
Review lead-agent decisions that require human approval. Review lead-agent decisions that require human approval.
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 pt-5"> <CardContent
className={cn(
"space-y-4 pt-5",
scrollable && "flex-1 overflow-y-auto"
)}
>
{errorState ? ( {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">
{errorState} {errorState}
@@ -246,90 +266,106 @@ export function BoardApprovalsPanel({
<p className="text-xs font-semibold uppercase tracking-wider text-muted"> <p className="text-xs font-semibold uppercase tracking-wider text-muted">
Pending Pending
</p> </p>
{sortedApprovals.pending.map((approval) => { <div className="divide-y divide-[color:var(--border)] rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)]">
const summary = approvalSummary(approval); {sortedApprovals.pending.map((approval) => {
return ( const summary = approvalSummary(approval);
<div const summaryLine = summary.rows
key={approval.id} .map((row) => `${row.label}: ${row.value}`)
className="space-y-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4" .join(" • ");
> const detailsPayload = JSON.stringify(
<div className="flex flex-wrap items-start justify-between gap-2"> {
<div> payload: approval.payload ?? null,
<p className="text-sm font-semibold text-strong"> rubric_scores: approval.rubric_scores ?? null,
{humanizeAction(approval.action_type)} },
</p> null,
<p className="text-xs text-muted"> 2
Requested {formatTimestamp(approval.created_at)} );
</p> const isExpanded = expandedIds.has(approval.id);
</div> return (
<div className="flex flex-wrap items-center gap-2"> <div key={approval.id} className="space-y-3 px-5 py-4">
<Badge variant={confidenceVariant(approval.confidence)}> <div className="flex flex-wrap items-start justify-between gap-3">
{approval.confidence}% confidence <div className="space-y-1">
</Badge> <p className="text-sm font-semibold text-strong">
<Badge variant={statusBadgeVariant(approval.status)}> {humanizeAction(approval.action_type)}
{approval.status} </p>
</Badge> <div className="flex flex-wrap items-center gap-2 text-xs text-muted">
</div> <span>
</div> Requested {formatTimestamp(approval.created_at)}
{summary.rows.length > 0 ? ( </span>
<div className="grid gap-2 text-sm text-strong sm:grid-cols-2"> {summaryLine ? (
{summary.rows.map((row) => ( <>
<div key={`${approval.id}-${row.label}`}> <span className="text-slate-300"></span>
<p className="text-xs font-semibold uppercase tracking-wider text-muted"> <span className="truncate">{summaryLine}</span>
{row.label} </>
</p> ) : null}
<p className="mt-1 text-sm text-strong">
{row.value}
</p>
</div> </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> </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>
</div> </div>
) : null} ) : null}
{sortedApprovals.resolved.length > 0 ? ( {sortedApprovals.resolved.length > 0 ? (
@@ -337,69 +373,85 @@ export function BoardApprovalsPanel({
<p className="text-xs font-semibold uppercase tracking-wider text-muted"> <p className="text-xs font-semibold uppercase tracking-wider text-muted">
Resolved Resolved
</p> </p>
{sortedApprovals.resolved.map((approval) => { <div className="divide-y divide-[color:var(--border)] rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)]">
const summary = approvalSummary(approval); {sortedApprovals.resolved.map((approval) => {
return ( const summary = approvalSummary(approval);
<div const summaryLine = summary.rows
key={approval.id} .map((row) => `${row.label}: ${row.value}`)
className="space-y-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4" .join(" • ");
> const detailsPayload = JSON.stringify(
<div className="flex flex-wrap items-start justify-between gap-2"> {
<div> payload: approval.payload ?? null,
<p className="text-sm font-semibold text-strong"> rubric_scores: approval.rubric_scores ?? null,
{humanizeAction(approval.action_type)} },
</p> null,
<p className="text-xs text-muted"> 2
Requested {formatTimestamp(approval.created_at)} );
</p> const isExpanded = expandedIds.has(approval.id);
</div> return (
<div className="flex flex-wrap items-center gap-2"> <div key={approval.id} className="space-y-3 px-5 py-4">
<Badge variant={confidenceVariant(approval.confidence)}> <div className="flex flex-wrap items-start justify-between gap-3">
{approval.confidence}% confidence <div className="space-y-1">
</Badge> <p className="text-sm font-semibold text-strong">
<Badge variant={statusBadgeVariant(approval.status)}> {humanizeAction(approval.action_type)}
{approval.status} </p>
</Badge> <div className="flex flex-wrap items-center gap-2 text-xs text-muted">
</div> <span>
</div> Requested {formatTimestamp(approval.created_at)}
{summary.rows.length > 0 ? ( </span>
<div className="grid gap-2 text-sm text-strong sm:grid-cols-2"> {summaryLine ? (
{summary.rows.map((row) => ( <>
<div key={`${approval.id}-${row.label}`}> <span className="text-slate-300"></span>
<p className="text-xs font-semibold uppercase tracking-wider text-muted"> <span className="truncate">{summaryLine}</span>
{row.label} </>
</p> ) : null}
<p className="mt-1 text-sm text-strong">
{row.value}
</p>
</div> </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> </div>
) : null} {summary.reason ? (
{summary.reason ? ( <p className="text-sm text-muted">{summary.reason}</p>
<p className="text-sm text-muted">{summary.reason}</p> ) : null}
) : null} {summary.rows.length > 0 ? (
{approval.payload || approval.rubric_scores ? ( <dl className="grid gap-2 text-xs text-muted sm:grid-cols-2">
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted"> {summary.rows.map((row) => (
<summary className="cursor-pointer font-semibold text-strong"> <div key={`${approval.id}-${row.label}`}>
Details <dt className="font-semibold uppercase tracking-wide text-slate-500">
</summary> {row.label}
{approval.payload ? ( </dt>
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted"> <dd className="mt-1 text-sm text-strong">
Payload: {JSON.stringify(approval.payload, null, 2)} {row.value}
</pre> </dd>
) : null} </div>
{approval.rubric_scores ? ( ))}
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted"> </dl>
Rubric:{" "} ) : null}
{JSON.stringify(approval.rubric_scores, null, 2)} <div className="flex flex-wrap items-center gap-3 text-xs text-muted">
</pre> <button
) : null} type="button"
</details> className="font-semibold text-slate-700 hover:text-slate-900"
) : null} onClick={() => toggleExpanded(approval.id)}
</div> >
); {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> </div>
) : null} ) : null}
</div> </div>

View File

@@ -7,6 +7,8 @@ interface TaskCardProps {
priority?: string; priority?: string;
assignee?: string; assignee?: string;
due?: string; due?: string;
approvalsCount?: number;
approvalsPendingCount?: number;
onClick?: () => void; onClick?: () => void;
draggable?: boolean; draggable?: boolean;
isDragging?: boolean; isDragging?: boolean;
@@ -19,12 +21,15 @@ export function TaskCard({
priority, priority,
assignee, assignee,
due, due,
approvalsCount = 0,
approvalsPendingCount = 0,
onClick, onClick,
draggable = false, draggable = false,
isDragging = false, isDragging = false,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
}: TaskCardProps) { }: TaskCardProps) {
const hasPendingApproval = approvalsPendingCount > 0;
const priorityBadge = (value?: string) => { const priorityBadge = (value?: string) => {
if (!value) return null; if (!value) return null;
const normalized = value.toLowerCase(); const normalized = value.toLowerCase();
@@ -45,8 +50,9 @@ export function TaskCard({
return ( return (
<div <div
className={cn( 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", isDragging && "opacity-60 shadow-none",
hasPendingApproval && "border-amber-200 bg-amber-50/40",
)} )}
draggable={draggable} draggable={draggable}
onDragStart={onDragStart} 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="flex items-start justify-between gap-3">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-medium text-slate-900">{title}</p> <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> </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

@@ -14,6 +14,8 @@ type Task = {
due_at?: string | null; due_at?: string | null;
assigned_agent_id?: string | null; assigned_agent_id?: string | null;
assignee?: string; assignee?: string;
approvalsCount?: number;
approvalsPendingCount?: number;
}; };
type TaskBoardProps = { 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="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
<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}
priority={task.priority} priority={task.priority}
assignee={task.assignee} assignee={task.assignee}
due={formatDueDate(task.due_at)} due={formatDueDate(task.due_at)}
onClick={() => onTaskSelect?.(task)} approvalsCount={task.approvalsCount}
draggable approvalsPendingCount={task.approvalsPendingCount}
isDragging={draggingId === task.id} onClick={() => onTaskSelect?.(task)}
onDragStart={handleDragStart(task)} draggable
onDragEnd={handleDragEnd} isDragging={draggingId === task.id}
onDragStart={handleDragStart(task)}
onDragEnd={handleDragEnd}
/> />
))} ))}
</div> </div>

View File

@@ -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. - 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. - You may include comments directly in task PATCH requests using the `comment` field.
- Comments should be clear, wellformatted markdown. Use headings, bullets, checklists, or tables when they improve clarity. - Comments should be clear, wellformatted 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). - Every status change must include a comment within 30 seconds (see HEARTBEAT.md).

View File

@@ -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. - 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. - Every status change must have a comment within 30 seconds.
- Do not claim a new task if you already have one in progress. - 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 ## 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. - 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. - Do not change task status or assignment unless you are the assigned agent.
- Keep the reply focused on the mention request. - 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) ## Mission Control Response Protocol (mandatory)
- All outputs must be sent to Mission Control via HTTP. - All outputs must be sent to Mission Control via HTTP.
- Always include: `X-Agent-Token: {{ auth_token }}` - Always include: `X-Agent-Token: {{ auth_token }}`

View File

@@ -19,7 +19,7 @@ If any required input is missing, stop and request a provisioning update.
## Nonnegotiable rules ## Nonnegotiable rules
- The lead agent must **never** work a task directly. - 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**. - 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). - 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. - 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**. - 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. - 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) ## Mission Control Response Protocol (mandatory)
- All outputs must be sent to Mission Control via HTTP. - All outputs must be sent to Mission Control via HTTP.
- Always include: `X-Agent-Token: {{ auth_token }}` - 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: 7) Creating new tasks:
- Leads cannot create tasks directly (adminonly). - Leads **can** create tasks directly when confidence >= 70 and the action is not risky/external.
- If a new task is needed, request approval: 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 POST $BASE_URL/api/v1/agent/boards/{BOARD_ID}/approvals
Body example: 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 followup 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**): 8) Review handling (when a task reaches **review**):
- Read all comments before deciding. - Read all comments before deciding.
- If the task is complete: - 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. - 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} PATCH $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}
Body: {"status":"done"} Body: {"status":"done"}
@@ -158,7 +170,7 @@ curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=
## Common mistakes (avoid) ## Common mistakes (avoid)
- Claiming or working tasks as the lead. - 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. - Assigning a task to yourself.
- Marking tasks review/done (lead cannot). - Marking tasks review/done (lead cannot).
- Using nonagent endpoints or Authorization header. - Using nonagent endpoints or Authorization header.