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,
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, well‑formatted markdown. Use headings, bullets, checklists, or tables when they improve clarity.
|
- 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).
|
- 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.
|
- 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 }}`
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ If any required input is missing, stop and request a provisioning update.
|
|||||||
|
|
||||||
## Non‑negotiable rules
|
## Non‑negotiable 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 (admin‑only).
|
- 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 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**):
|
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 non‑agent endpoints or Authorization header.
|
- Using non‑agent endpoints or Authorization header.
|
||||||
|
|||||||
Reference in New Issue
Block a user