feat: enhance agent creation with human-like naming and improve task assignment notifications
This commit is contained in:
@@ -332,7 +332,11 @@ async def agent_heartbeat(
|
|||||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||||
) -> AgentRead:
|
) -> AgentRead:
|
||||||
if agent_ctx.agent.name != payload.name:
|
if agent_ctx.agent.name != payload.name:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
payload = AgentHeartbeatCreate(
|
||||||
|
name=agent_ctx.agent.name,
|
||||||
|
status=payload.status,
|
||||||
|
board_id=payload.board_id,
|
||||||
|
)
|
||||||
return await agents_api.heartbeat_or_create_agent( # type: ignore[attr-defined]
|
return await agents_api.heartbeat_or_create_agent( # type: ignore[attr-defined]
|
||||||
payload=payload,
|
payload=payload,
|
||||||
session=session,
|
session=session,
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from sqlalchemy import update
|
from sqlalchemy import asc, or_, update
|
||||||
from sqlmodel import Session, col, select
|
from sqlmodel import Session, col, select
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
|
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
|
||||||
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
from app.core.agent_tokens import generate_agent_token, hash_agent_token
|
||||||
from app.core.auth import AuthContext
|
from app.core.auth import AuthContext
|
||||||
from app.db.session import get_session
|
from app.db.session import engine, get_session
|
||||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||||
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
|
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
|
||||||
from app.models.activity_events import ActivityEvent
|
from app.models.activity_events import ActivityEvent
|
||||||
@@ -34,6 +38,22 @@ OFFLINE_AFTER = timedelta(minutes=10)
|
|||||||
AGENT_SESSION_PREFIX = "agent"
|
AGENT_SESSION_PREFIX = "agent"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_since(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
normalized = normalized.replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is not None:
|
||||||
|
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def _normalize_identity_profile(
|
def _normalize_identity_profile(
|
||||||
profile: dict[str, object] | None,
|
profile: dict[str, object] | None,
|
||||||
) -> dict[str, str] | None:
|
) -> dict[str, str] | None:
|
||||||
@@ -172,6 +192,30 @@ def _with_computed_status(agent: Agent) -> Agent:
|
|||||||
return agent
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_agent(agent: Agent, main_session_keys: set[str]) -> dict[str, object]:
|
||||||
|
return _to_agent_read(_with_computed_status(agent), main_session_keys).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_agent_events(
|
||||||
|
board_id: UUID | None,
|
||||||
|
since: datetime,
|
||||||
|
) -> list[Agent]:
|
||||||
|
with Session(engine) as session:
|
||||||
|
statement = select(Agent)
|
||||||
|
if board_id:
|
||||||
|
statement = statement.where(col(Agent.board_id) == board_id)
|
||||||
|
statement = (
|
||||||
|
statement.where(
|
||||||
|
or_(
|
||||||
|
col(Agent.updated_at) >= since,
|
||||||
|
col(Agent.last_seen_at) >= since,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(asc(col(Agent.updated_at)))
|
||||||
|
)
|
||||||
|
return list(session.exec(statement))
|
||||||
|
|
||||||
|
|
||||||
def _record_heartbeat(session: Session, agent: Agent) -> None:
|
def _record_heartbeat(session: Session, agent: Agent) -> None:
|
||||||
record_activity(
|
record_activity(
|
||||||
session,
|
session,
|
||||||
@@ -217,6 +261,36 @@ def list_agents(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream")
|
||||||
|
async def stream_agents(
|
||||||
|
request: Request,
|
||||||
|
board_id: UUID | None = Query(default=None),
|
||||||
|
since: str | None = Query(default=None),
|
||||||
|
auth: AuthContext = Depends(require_admin_auth),
|
||||||
|
) -> EventSourceResponse:
|
||||||
|
since_dt = _parse_since(since) or datetime.utcnow()
|
||||||
|
last_seen = since_dt
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
nonlocal last_seen
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
agents = await run_in_threadpool(_fetch_agent_events, board_id, last_seen)
|
||||||
|
if agents:
|
||||||
|
with Session(engine) as session:
|
||||||
|
main_session_keys = _get_gateway_main_session_keys(session)
|
||||||
|
for agent in agents:
|
||||||
|
updated_at = agent.updated_at or agent.last_seen_at or datetime.utcnow()
|
||||||
|
if updated_at > last_seen:
|
||||||
|
last_seen = updated_at
|
||||||
|
payload = {"agent": _serialize_agent(agent, main_session_keys)}
|
||||||
|
yield {"event": "agent", "data": json.dumps(payload)}
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
return EventSourceResponse(event_generator(), ping=15)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AgentRead)
|
@router.post("", response_model=AgentRead)
|
||||||
async def create_agent(
|
async def create_agent(
|
||||||
payload: AgentCreate,
|
payload: AgentCreate,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
from sqlalchemy import asc, or_
|
||||||
from sqlmodel import Session, col, select
|
from sqlmodel import Session, col, select
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
|
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
|
||||||
from app.db.session import get_session
|
from app.db.session import engine, get_session
|
||||||
from app.models.approvals import Approval
|
from app.models.approvals import Approval
|
||||||
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalUpdate
|
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalUpdate
|
||||||
|
|
||||||
@@ -15,6 +21,49 @@ router = APIRouter(prefix="/boards/{board_id}/approvals", tags=["approvals"])
|
|||||||
ALLOWED_STATUSES = {"pending", "approved", "rejected"}
|
ALLOWED_STATUSES = {"pending", "approved", "rejected"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_since(value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
normalized = normalized.replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is not None:
|
||||||
|
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _approval_updated_at(approval: Approval) -> datetime:
|
||||||
|
return approval.resolved_at or approval.created_at
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_approval(approval: Approval) -> dict[str, object]:
|
||||||
|
return ApprovalRead.model_validate(approval, from_attributes=True).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_approval_events(
|
||||||
|
board_id: UUID,
|
||||||
|
since: datetime,
|
||||||
|
) -> list[Approval]:
|
||||||
|
with Session(engine) as session:
|
||||||
|
statement = (
|
||||||
|
select(Approval)
|
||||||
|
.where(col(Approval.board_id) == board_id)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
col(Approval.created_at) >= since,
|
||||||
|
col(Approval.resolved_at) >= since,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(asc(col(Approval.created_at)))
|
||||||
|
)
|
||||||
|
return list(session.exec(statement))
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ApprovalRead])
|
@router.get("", response_model=list[ApprovalRead])
|
||||||
def list_approvals(
|
def list_approvals(
|
||||||
status_filter: str | None = Query(default=None, alias="status"),
|
status_filter: str | None = Query(default=None, alias="status"),
|
||||||
@@ -34,6 +83,38 @@ def list_approvals(
|
|||||||
return list(session.exec(statement))
|
return list(session.exec(statement))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream")
|
||||||
|
async def stream_approvals(
|
||||||
|
request: Request,
|
||||||
|
board=Depends(get_board_or_404),
|
||||||
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
|
since: str | None = Query(default=None),
|
||||||
|
) -> EventSourceResponse:
|
||||||
|
if actor.actor_type == "agent" and actor.agent:
|
||||||
|
if actor.agent.board_id and actor.agent.board_id != board.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
since_dt = _parse_since(since) or datetime.utcnow()
|
||||||
|
last_seen = since_dt
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
nonlocal last_seen
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
approvals = await run_in_threadpool(
|
||||||
|
_fetch_approval_events, board.id, last_seen
|
||||||
|
)
|
||||||
|
for approval in approvals:
|
||||||
|
updated_at = _approval_updated_at(approval)
|
||||||
|
if updated_at > last_seen:
|
||||||
|
last_seen = updated_at
|
||||||
|
payload = {"approval": _serialize_approval(approval)}
|
||||||
|
yield {"event": "approval", "data": json.dumps(payload)}
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
return EventSourceResponse(event_generator(), ping=15)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ApprovalRead)
|
@router.post("", response_model=ApprovalRead)
|
||||||
def create_approval(
|
def create_approval(
|
||||||
payload: ApprovalCreate,
|
payload: ApprovalCreate,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from uuid import UUID
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
from sqlalchemy import asc, desc
|
from sqlalchemy import asc, desc, delete
|
||||||
from sqlmodel import Session, col, select
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
@@ -32,6 +32,7 @@ from app.models.agents import Agent
|
|||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.gateways import Gateway
|
from app.models.gateways import Gateway
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
|
from app.models.task_fingerprints import TaskFingerprint
|
||||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
||||||
from app.services.activity_log import record_activity
|
from app.services.activity_log import record_activity
|
||||||
|
|
||||||
@@ -150,6 +151,73 @@ async def _send_lead_task_message(
|
|||||||
await send_message(message, session_key=session_key, config=config, deliver=False)
|
await send_message(message, session_key=session_key, config=config, deliver=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_agent_task_message(
|
||||||
|
*,
|
||||||
|
session_key: str,
|
||||||
|
config: GatewayClientConfig,
|
||||||
|
agent_name: str,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
await ensure_session(session_key, config=config, label=agent_name)
|
||||||
|
await send_message(message, session_key=session_key, config=config, deliver=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_agent_on_task_assign(
|
||||||
|
*,
|
||||||
|
session: Session,
|
||||||
|
board: Board,
|
||||||
|
task: Task,
|
||||||
|
agent: Agent,
|
||||||
|
) -> None:
|
||||||
|
if not agent.openclaw_session_id:
|
||||||
|
return
|
||||||
|
config = _gateway_config(session, board)
|
||||||
|
if config is None:
|
||||||
|
return
|
||||||
|
description = (task.description or "").strip()
|
||||||
|
if len(description) > 500:
|
||||||
|
description = f"{description[:497]}..."
|
||||||
|
details = [
|
||||||
|
f"Board: {board.name}",
|
||||||
|
f"Task: {task.title}",
|
||||||
|
f"Task ID: {task.id}",
|
||||||
|
f"Status: {task.status}",
|
||||||
|
]
|
||||||
|
if description:
|
||||||
|
details.append(f"Description: {description}")
|
||||||
|
message = (
|
||||||
|
"TASK ASSIGNED\n"
|
||||||
|
+ "\n".join(details)
|
||||||
|
+ "\n\nTake action: open the task and begin work. Post updates as task comments."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
asyncio.run(
|
||||||
|
_send_agent_task_message(
|
||||||
|
session_key=agent.openclaw_session_id,
|
||||||
|
config=config,
|
||||||
|
agent_name=agent.name,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
record_activity(
|
||||||
|
session,
|
||||||
|
event_type="task.assignee_notified",
|
||||||
|
message=f"Agent notified for assignment: {agent.name}.",
|
||||||
|
agent_id=agent.id,
|
||||||
|
task_id=task.id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except OpenClawGatewayError as exc:
|
||||||
|
record_activity(
|
||||||
|
session,
|
||||||
|
event_type="task.assignee_notify_failed",
|
||||||
|
message=f"Assignee notify failed: {exc}",
|
||||||
|
agent_id=agent.id,
|
||||||
|
task_id=task.id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
def _notify_lead_on_task_create(
|
def _notify_lead_on_task_create(
|
||||||
*,
|
*,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -300,6 +368,15 @@ def create_task(
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
_notify_lead_on_task_create(session=session, board=board, task=task)
|
_notify_lead_on_task_create(session=session, board=board, task=task)
|
||||||
|
if task.assigned_agent_id:
|
||||||
|
assigned_agent = session.get(Agent, task.assigned_agent_id)
|
||||||
|
if assigned_agent:
|
||||||
|
_notify_agent_on_task_assign(
|
||||||
|
session=session,
|
||||||
|
board=board,
|
||||||
|
task=task,
|
||||||
|
agent=assigned_agent,
|
||||||
|
)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
@@ -311,6 +388,7 @@ def update_task(
|
|||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> Task:
|
) -> Task:
|
||||||
previous_status = task.status
|
previous_status = task.status
|
||||||
|
previous_assigned = task.assigned_agent_id
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
comment = updates.pop("comment", None)
|
comment = updates.pop("comment", None)
|
||||||
if comment is not None and not comment.strip():
|
if comment is not None and not comment.strip():
|
||||||
@@ -431,6 +509,23 @@ def update_task(
|
|||||||
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
|
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
if task.assigned_agent_id and task.assigned_agent_id != previous_assigned:
|
||||||
|
if (
|
||||||
|
actor.actor_type == "agent"
|
||||||
|
and actor.agent
|
||||||
|
and task.assigned_agent_id == actor.agent.id
|
||||||
|
):
|
||||||
|
return task
|
||||||
|
assigned_agent = session.get(Agent, task.assigned_agent_id)
|
||||||
|
if assigned_agent:
|
||||||
|
board = session.get(Board, task.board_id) if task.board_id else None
|
||||||
|
if board:
|
||||||
|
_notify_agent_on_task_assign(
|
||||||
|
session=session,
|
||||||
|
board=board,
|
||||||
|
task=task,
|
||||||
|
agent=assigned_agent,
|
||||||
|
)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
@@ -440,6 +535,8 @@ def delete_task(
|
|||||||
task: Task = Depends(get_task_or_404),
|
task: Task = Depends(get_task_or_404),
|
||||||
auth: AuthContext = Depends(require_admin_auth),
|
auth: AuthContext = Depends(require_admin_auth),
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
|
session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id))
|
||||||
|
session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id))
|
||||||
session.delete(task)
|
session.delete(task)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ def _build_context(
|
|||||||
context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field])
|
context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field])
|
||||||
for field, context_key in IDENTITY_PROFILE_FIELDS.items()
|
for field, context_key in IDENTITY_PROFILE_FIELDS.items()
|
||||||
}
|
}
|
||||||
|
preferred_name = (user.preferred_name or "") if user else ""
|
||||||
|
if preferred_name:
|
||||||
|
preferred_name = preferred_name.strip().split()[0]
|
||||||
return {
|
return {
|
||||||
"agent_name": agent.name,
|
"agent_name": agent.name,
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
@@ -162,7 +165,7 @@ def _build_context(
|
|||||||
"main_session_key": main_session_key,
|
"main_session_key": main_session_key,
|
||||||
"workspace_root": workspace_root,
|
"workspace_root": workspace_root,
|
||||||
"user_name": (user.name or "") if user else "",
|
"user_name": (user.name or "") if user else "",
|
||||||
"user_preferred_name": (user.preferred_name or "") if user else "",
|
"user_preferred_name": preferred_name,
|
||||||
"user_pronouns": (user.pronouns or "") if user else "",
|
"user_pronouns": (user.pronouns or "") if user else "",
|
||||||
"user_timezone": (user.timezone or "") if user else "",
|
"user_timezone": (user.timezone or "") if user else "",
|
||||||
"user_notes": (user.notes or "") if user else "",
|
"user_notes": (user.notes or "") if user else "",
|
||||||
@@ -198,6 +201,9 @@ def _build_main_context(
|
|||||||
context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field])
|
context_key: normalized_identity.get(field, DEFAULT_IDENTITY_PROFILE[field])
|
||||||
for field, context_key in IDENTITY_PROFILE_FIELDS.items()
|
for field, context_key in IDENTITY_PROFILE_FIELDS.items()
|
||||||
}
|
}
|
||||||
|
preferred_name = (user.preferred_name or "") if user else ""
|
||||||
|
if preferred_name:
|
||||||
|
preferred_name = preferred_name.strip().split()[0]
|
||||||
return {
|
return {
|
||||||
"agent_name": agent.name,
|
"agent_name": agent.name,
|
||||||
"agent_id": str(agent.id),
|
"agent_id": str(agent.id),
|
||||||
@@ -207,7 +213,7 @@ def _build_main_context(
|
|||||||
"main_session_key": gateway.main_session_key or "",
|
"main_session_key": gateway.main_session_key or "",
|
||||||
"workspace_root": gateway.workspace_root or "",
|
"workspace_root": gateway.workspace_root or "",
|
||||||
"user_name": (user.name or "") if user else "",
|
"user_name": (user.name or "") if user else "",
|
||||||
"user_preferred_name": (user.preferred_name or "") if user else "",
|
"user_preferred_name": preferred_name,
|
||||||
"user_pronouns": (user.pronouns or "") if user else "",
|
"user_pronouns": (user.pronouns or "") if user else "",
|
||||||
"user_timezone": (user.timezone or "") if user else "",
|
"user_timezone": (user.timezone or "") if user else "",
|
||||||
"user_notes": (user.notes or "") if user else "",
|
"user_notes": (user.notes or "") if user else "",
|
||||||
@@ -449,7 +455,8 @@ async def provision_agent(
|
|||||||
await _patch_gateway_agent_list(agent_id, workspace_path, heartbeat, client_config)
|
await _patch_gateway_agent_list(agent_id, workspace_path, heartbeat, client_config)
|
||||||
|
|
||||||
context = _build_context(agent, board, gateway, auth_token, user)
|
context = _build_context(agent, board, gateway, auth_token, user)
|
||||||
supported = await _supported_gateway_files(client_config)
|
supported = set(await _supported_gateway_files(client_config))
|
||||||
|
supported.add("USER.md")
|
||||||
existing_files = await _gateway_agent_files_index(agent_id, client_config)
|
existing_files = await _gateway_agent_files_index(agent_id, client_config)
|
||||||
include_bootstrap = True
|
include_bootstrap = True
|
||||||
if action == "update" and not force_bootstrap:
|
if action == "update" and not force_bootstrap:
|
||||||
@@ -500,7 +507,8 @@ async def provision_main_agent(
|
|||||||
raise OpenClawGatewayError("Unable to resolve gateway main agent id")
|
raise OpenClawGatewayError("Unable to resolve gateway main agent id")
|
||||||
|
|
||||||
context = _build_main_context(agent, gateway, auth_token, user)
|
context = _build_main_context(agent, gateway, auth_token, user)
|
||||||
supported = await _supported_gateway_files(client_config)
|
supported = set(await _supported_gateway_files(client_config))
|
||||||
|
supported.add("USER.md")
|
||||||
existing_files = await _gateway_agent_files_index(agent_id, client_config)
|
existing_files = await _gateway_agent_files_index(agent_id, client_config)
|
||||||
include_bootstrap = action != "update" or force_bootstrap
|
include_bootstrap = action != "update" or force_bootstrap
|
||||||
if action == "update" and not force_bootstrap:
|
if action == "update" and not force_bootstrap:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||||
import { Pencil, X } from "lucide-react";
|
import { Pencil, Settings, X } from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
|
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
|
||||||
@@ -61,6 +61,8 @@ type Agent = {
|
|||||||
status: string;
|
status: string;
|
||||||
board_id?: string | null;
|
board_id?: string | null;
|
||||||
is_board_lead?: boolean;
|
is_board_lead?: boolean;
|
||||||
|
updated_at?: string | null;
|
||||||
|
last_seen_at?: string | null;
|
||||||
identity_profile?: {
|
identity_profile?: {
|
||||||
emoji?: string | null;
|
emoji?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -130,8 +132,11 @@ export default function BoardDetailPage() {
|
|||||||
const [commentsError, setCommentsError] = useState<string | null>(null);
|
const [commentsError, setCommentsError] = useState<string | null>(null);
|
||||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
const tasksRef = useRef<Task[]>([]);
|
const tasksRef = useRef<Task[]>([]);
|
||||||
|
const approvalsRef = useRef<Approval[]>([]);
|
||||||
|
const agentsRef = useRef<Agent[]>([]);
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
const [isApprovalsOpen, setIsApprovalsOpen] = useState(false);
|
const [isApprovalsOpen, setIsApprovalsOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
const [approvals, setApprovals] = useState<Approval[]>([]);
|
const [approvals, setApprovals] = useState<Approval[]>([]);
|
||||||
const [isApprovalsLoading, setIsApprovalsLoading] = useState(false);
|
const [isApprovalsLoading, setIsApprovalsLoading] = useState(false);
|
||||||
@@ -139,6 +144,9 @@ export default function BoardDetailPage() {
|
|||||||
const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>(
|
const [approvalsUpdatingId, setApprovalsUpdatingId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [isDeletingTask, setIsDeletingTask] = useState(false);
|
||||||
|
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<"board" | "list">("board");
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -173,6 +181,32 @@ export default function BoardDetailPage() {
|
|||||||
return latestTime ? new Date(latestTime).toISOString() : null;
|
return latestTime ? new Date(latestTime).toISOString() : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const latestApprovalTimestamp = (items: Approval[]) => {
|
||||||
|
let latestTime = 0;
|
||||||
|
items.forEach((approval) => {
|
||||||
|
const value = approval.resolved_at ?? approval.created_at;
|
||||||
|
if (!value) return;
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
if (!Number.isNaN(time) && time > latestTime) {
|
||||||
|
latestTime = time;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return latestTime ? new Date(latestTime).toISOString() : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const latestAgentTimestamp = (items: Agent[]) => {
|
||||||
|
let latestTime = 0;
|
||||||
|
items.forEach((agent) => {
|
||||||
|
const value = agent.updated_at ?? agent.last_seen_at;
|
||||||
|
if (!value) return;
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
if (!Number.isNaN(time) && time > latestTime) {
|
||||||
|
latestTime = time;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return latestTime ? new Date(latestTime).toISOString() : null;
|
||||||
|
};
|
||||||
|
|
||||||
const loadBoard = async () => {
|
const loadBoard = async () => {
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -229,6 +263,14 @@ export default function BoardDetailPage() {
|
|||||||
tasksRef.current = tasks;
|
tasksRef.current = tasks;
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
approvalsRef.current = approvals;
|
||||||
|
}, [approvals]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
agentsRef.current = agents;
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
const loadApprovals = useCallback(async () => {
|
const loadApprovals = useCallback(async () => {
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
setIsApprovalsLoading(true);
|
setIsApprovalsLoading(true);
|
||||||
@@ -259,11 +301,96 @@ export default function BoardDetailPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadApprovals();
|
loadApprovals();
|
||||||
if (!isSignedIn || !boardId) return;
|
|
||||||
const interval = setInterval(loadApprovals, 15000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [boardId, isSignedIn, loadApprovals]);
|
}, [boardId, isSignedIn, loadApprovals]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSignedIn || !boardId) return;
|
||||||
|
let isCancelled = false;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token || isCancelled) return;
|
||||||
|
const url = new URL(
|
||||||
|
`${apiBase}/api/v1/boards/${boardId}/approvals/stream`,
|
||||||
|
);
|
||||||
|
const since = latestApprovalTimestamp(approvalsRef.current);
|
||||||
|
if (since) {
|
||||||
|
url.searchParams.set("since", since);
|
||||||
|
}
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error("Unable to connect approvals stream.");
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (!isCancelled) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
buffer = buffer.replace(/\r\n/g, "\n");
|
||||||
|
let boundary = buffer.indexOf("\n\n");
|
||||||
|
while (boundary !== -1) {
|
||||||
|
const raw = buffer.slice(0, boundary);
|
||||||
|
buffer = buffer.slice(boundary + 2);
|
||||||
|
const lines = raw.split("\n");
|
||||||
|
let eventType = "message";
|
||||||
|
let data = "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
eventType = line.slice(6).trim();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
data += line.slice(5).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (eventType === "approval" && data) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data) as { approval?: Approval };
|
||||||
|
if (payload.approval) {
|
||||||
|
setApprovals((prev) => {
|
||||||
|
const index = prev.findIndex(
|
||||||
|
(item) => item.id === payload.approval?.id,
|
||||||
|
);
|
||||||
|
if (index === -1) {
|
||||||
|
return [payload.approval as Approval, ...prev];
|
||||||
|
}
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = {
|
||||||
|
...next[index],
|
||||||
|
...(payload.approval as Approval),
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed payloads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boundary = buffer.indexOf("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setTimeout(connect, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}, [boardId, getToken, isSignedIn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedTask) {
|
if (!selectedTask) {
|
||||||
setEditTitle("");
|
setEditTitle("");
|
||||||
@@ -378,6 +505,93 @@ export default function BoardDetailPage() {
|
|||||||
};
|
};
|
||||||
}, [board, boardId, getToken, isSignedIn, selectedTask?.id]);
|
}, [board, boardId, getToken, isSignedIn, selectedTask?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSignedIn || !boardId) return;
|
||||||
|
let isCancelled = false;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token || isCancelled) return;
|
||||||
|
const url = new URL(`${apiBase}/api/v1/agents/stream`);
|
||||||
|
url.searchParams.set("board_id", boardId);
|
||||||
|
const since = latestAgentTimestamp(agentsRef.current);
|
||||||
|
if (since) {
|
||||||
|
url.searchParams.set("since", since);
|
||||||
|
}
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error("Unable to connect agent stream.");
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (!isCancelled) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
buffer = buffer.replace(/\r\n/g, "\n");
|
||||||
|
let boundary = buffer.indexOf("\n\n");
|
||||||
|
while (boundary !== -1) {
|
||||||
|
const raw = buffer.slice(0, boundary);
|
||||||
|
buffer = buffer.slice(boundary + 2);
|
||||||
|
const lines = raw.split("\n");
|
||||||
|
let eventType = "message";
|
||||||
|
let data = "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
eventType = line.slice(6).trim();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
data += line.slice(5).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (eventType === "agent" && data) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data) as { agent?: Agent };
|
||||||
|
if (payload.agent) {
|
||||||
|
setAgents((prev) => {
|
||||||
|
const index = prev.findIndex(
|
||||||
|
(item) => item.id === payload.agent?.id,
|
||||||
|
);
|
||||||
|
if (index === -1) {
|
||||||
|
return [payload.agent as Agent, ...prev];
|
||||||
|
}
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = {
|
||||||
|
...next[index],
|
||||||
|
...(payload.agent as Agent),
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed payloads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boundary = buffer.indexOf("\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setTimeout(connect, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}, [boardId, getToken, isSignedIn]);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
@@ -622,6 +836,79 @@ export default function BoardDetailPage() {
|
|||||||
setSaveTaskError(null);
|
setSaveTaskError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteTask = async () => {
|
||||||
|
if (!selectedTask || !boardId || !isSignedIn) return;
|
||||||
|
setIsDeletingTask(true);
|
||||||
|
setDeleteTaskError(null);
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
const response = await fetch(
|
||||||
|
`${apiBase}/api/v1/boards/${boardId}/tasks/${selectedTask.id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: token ? `Bearer ${token}` : "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Unable to delete task.");
|
||||||
|
}
|
||||||
|
setTasks((prev) => prev.filter((task) => task.id !== selectedTask.id));
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
closeComments();
|
||||||
|
} catch (err) {
|
||||||
|
setDeleteTaskError(
|
||||||
|
err instanceof Error ? err.message : "Something went wrong.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsDeletingTask(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskMove = async (taskId: string, status: string) => {
|
||||||
|
if (!isSignedIn || !boardId) return;
|
||||||
|
const currentTask = tasksRef.current.find((task) => task.id === taskId);
|
||||||
|
if (!currentTask || currentTask.status === status) return;
|
||||||
|
const previousTasks = tasksRef.current;
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((task) =>
|
||||||
|
task.id === taskId
|
||||||
|
? {
|
||||||
|
...task,
|
||||||
|
status,
|
||||||
|
assigned_agent_id:
|
||||||
|
status === "inbox" ? null : task.assigned_agent_id,
|
||||||
|
}
|
||||||
|
: task,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
const response = await fetch(
|
||||||
|
`${apiBase}/api/v1/boards/${boardId}/tasks/${taskId}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: token ? `Bearer ${token}` : "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Unable to move task.");
|
||||||
|
}
|
||||||
|
const updated = (await response.json()) as Task;
|
||||||
|
setTasks((prev) =>
|
||||||
|
prev.map((task) => (task.id === updated.id ? updated : task)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setTasks(previousTasks);
|
||||||
|
setError(err instanceof Error ? err.message : "Unable to move task.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const agentInitials = (agent: Agent) =>
|
const agentInitials = (agent: Agent) =>
|
||||||
agent.name
|
agent.name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
@@ -664,6 +951,44 @@ export default function BoardDetailPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTaskTimestamp = (value?: string | null) => {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadgeClass = (value?: string) => {
|
||||||
|
switch (value) {
|
||||||
|
case "in_progress":
|
||||||
|
return "bg-purple-100 text-purple-700";
|
||||||
|
case "review":
|
||||||
|
return "bg-indigo-100 text-indigo-700";
|
||||||
|
case "done":
|
||||||
|
return "bg-emerald-100 text-emerald-700";
|
||||||
|
default:
|
||||||
|
return "bg-slate-100 text-slate-600";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityBadgeClass = (value?: string) => {
|
||||||
|
switch (value?.toLowerCase()) {
|
||||||
|
case "high":
|
||||||
|
return "bg-rose-100 text-rose-700";
|
||||||
|
case "medium":
|
||||||
|
return "bg-amber-100 text-amber-700";
|
||||||
|
case "low":
|
||||||
|
return "bg-emerald-100 text-emerald-700";
|
||||||
|
default:
|
||||||
|
return "bg-slate-100 text-slate-600";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatApprovalTimestamp = (value?: string | null) => {
|
const formatApprovalTimestamp = (value?: string | null) => {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -676,6 +1001,56 @@ export default function BoardDetailPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const humanizeApprovalAction = (value: string) =>
|
||||||
|
value
|
||||||
|
.split(".")
|
||||||
|
.map((part) =>
|
||||||
|
part
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||||
|
)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
const approvalPayloadValue = (
|
||||||
|
payload: Approval["payload"],
|
||||||
|
key: string,
|
||||||
|
) => {
|
||||||
|
if (!payload) return null;
|
||||||
|
const value = payload[key as keyof typeof payload];
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const approvalRows = (approval: Approval) => {
|
||||||
|
const payload = approval.payload ?? {};
|
||||||
|
const taskId =
|
||||||
|
approvalPayloadValue(payload, "task_id") ??
|
||||||
|
approvalPayloadValue(payload, "taskId") ??
|
||||||
|
approvalPayloadValue(payload, "taskID");
|
||||||
|
const assignedAgentId =
|
||||||
|
approvalPayloadValue(payload, "assigned_agent_id") ??
|
||||||
|
approvalPayloadValue(payload, "assignedAgentId");
|
||||||
|
const title = approvalPayloadValue(payload, "title");
|
||||||
|
const role = approvalPayloadValue(payload, "role");
|
||||||
|
const isAssign = approval.action_type.includes("assign");
|
||||||
|
const rows: Array<{ label: string; value: string }> = [];
|
||||||
|
if (taskId) rows.push({ label: "Task", value: taskId });
|
||||||
|
if (isAssign) {
|
||||||
|
rows.push({
|
||||||
|
label: "Assignee",
|
||||||
|
value: assignedAgentId ?? "Unassigned",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (title) rows.push({ label: "Title", value: title });
|
||||||
|
if (role) rows.push({ label: "Role", value: role });
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const approvalReason = (approval: Approval) =>
|
||||||
|
approvalPayloadValue(approval.payload ?? {}, "reason");
|
||||||
|
|
||||||
const handleApprovalDecision = useCallback(
|
const handleApprovalDecision = useCallback(
|
||||||
async (approvalId: string, status: "approved" | "rejected") => {
|
async (approvalId: string, status: "approved" | "rejected") => {
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
@@ -745,15 +1120,28 @@ export default function BoardDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-1">
|
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-1">
|
||||||
<button className="rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white">
|
<button
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
viewMode === "board"
|
||||||
|
? "bg-slate-900 text-white"
|
||||||
|
: "text-slate-600 hover:bg-slate-200 hover:text-slate-900",
|
||||||
|
)}
|
||||||
|
onClick={() => setViewMode("board")}
|
||||||
|
>
|
||||||
Board
|
Board
|
||||||
</button>
|
</button>
|
||||||
<button className="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900">
|
<button
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
viewMode === "list"
|
||||||
|
? "bg-slate-900 text-white"
|
||||||
|
: "text-slate-600 hover:bg-slate-200 hover:text-slate-900",
|
||||||
|
)}
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
>
|
||||||
List
|
List
|
||||||
</button>
|
</button>
|
||||||
<button className="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900">
|
|
||||||
Timeline
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setIsDialogOpen(true)}>
|
<Button onClick={() => setIsDialogOpen(true)}>
|
||||||
New task
|
New task
|
||||||
@@ -770,18 +1158,15 @@ export default function BoardDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
type="button"
|
||||||
onClick={() => router.push(`/boards/${boardId}/edit`)}
|
onClick={() => router.push(`/boards/${boardId}/edit`)}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
|
||||||
|
aria-label="Board settings"
|
||||||
|
title="Board settings"
|
||||||
>
|
>
|
||||||
Board settings
|
<Settings className="h-4 w-4" />
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push("/boards")}
|
|
||||||
>
|
|
||||||
Back to boards
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -863,12 +1248,98 @@ export default function BoardDetailPage() {
|
|||||||
Loading {titleLabel}…
|
Loading {titleLabel}…
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TaskBoard
|
<>
|
||||||
tasks={displayTasks}
|
{viewMode === "board" ? (
|
||||||
onCreateTask={() => setIsDialogOpen(true)}
|
<TaskBoard
|
||||||
isCreateDisabled={isCreating}
|
tasks={displayTasks}
|
||||||
onTaskSelect={openComments}
|
onCreateTask={() => setIsDialogOpen(true)}
|
||||||
/>
|
isCreateDisabled={isCreating}
|
||||||
|
onTaskSelect={openComments}
|
||||||
|
onTaskMove={handleTaskMove}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="border-b border-slate-200 px-5 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
|
All tasks
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{displayTasks.length} tasks in this board
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsDialogOpen(true)}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
New task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{displayTasks.length === 0 ? (
|
||||||
|
<div className="px-5 py-8 text-sm text-slate-500">
|
||||||
|
No tasks yet. Create your first task to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
displayTasks.map((task) => (
|
||||||
|
<button
|
||||||
|
key={task.id}
|
||||||
|
type="button"
|
||||||
|
className="w-full px-5 py-4 text-left transition hover:bg-slate-50"
|
||||||
|
onClick={() => openComments(task)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-slate-900">
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
{task.description
|
||||||
|
? task.description
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.slice(0, 120)
|
||||||
|
: "No description"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
||||||
|
statusBadgeClass(task.status),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.status.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
||||||
|
priorityBadgeClass(task.priority),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.priority}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{task.assignee ?? "Unassigned"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{formatTaskTimestamp(
|
||||||
|
task.updated_at ?? task.created_at,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -956,7 +1427,7 @@ export default function BoardDetailPage() {
|
|||||||
<div className="flex flex-wrap items-start justify-between gap-2 text-xs text-slate-500">
|
<div className="flex flex-wrap items-start justify-between gap-2 text-xs text-slate-500">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
{approval.action_type.replace(/_/g, " ")}
|
{humanizeApprovalAction(approval.action_type)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
Requested {formatApprovalTimestamp(approval.created_at)}
|
Requested {formatApprovalTimestamp(approval.created_at)}
|
||||||
@@ -966,10 +1437,24 @@ export default function BoardDetailPage() {
|
|||||||
{approval.confidence}% confidence · {approval.status}
|
{approval.confidence}% confidence · {approval.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{approval.payload ? (
|
{approvalRows(approval).length > 0 ? (
|
||||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-slate-600">
|
<div className="mt-2 grid gap-2 text-xs text-slate-600 sm:grid-cols-2">
|
||||||
{JSON.stringify(approval.payload, null, 2)}
|
{approvalRows(approval).map((row) => (
|
||||||
</pre>
|
<div key={`${approval.id}-${row.label}`}>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||||
|
{row.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-700">
|
||||||
|
{row.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{approvalReason(approval) ? (
|
||||||
|
<p className="mt-2 text-xs text-slate-600">
|
||||||
|
{approvalReason(approval)}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{approval.status === "pending" ? (
|
{approval.status === "pending" ? (
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
@@ -1082,7 +1567,16 @@ export default function BoardDetailPage() {
|
|||||||
Review pending decisions from your lead agent.
|
Review pending decisions from your lead agent.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{boardId ? <BoardApprovalsPanel boardId={boardId} /> : null}
|
{boardId ? (
|
||||||
|
<BoardApprovalsPanel
|
||||||
|
boardId={boardId}
|
||||||
|
approvals={approvals}
|
||||||
|
isLoading={isApprovalsLoading}
|
||||||
|
error={approvalsError}
|
||||||
|
onDecision={handleApprovalDecision}
|
||||||
|
onRefresh={loadApprovals}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
@@ -1198,6 +1692,14 @@ export default function BoardDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="flex flex-wrap gap-2">
|
<DialogFooter className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsDeleteDialogOpen(true)}
|
||||||
|
disabled={!selectedTask || isSavingTask}
|
||||||
|
className="border-rose-200 text-rose-600 hover:border-rose-300 hover:text-rose-700"
|
||||||
|
>
|
||||||
|
Delete task
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleTaskReset}
|
onClick={handleTaskReset}
|
||||||
@@ -1215,6 +1717,38 @@ export default function BoardDetailPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<DialogContent aria-label="Delete task">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete task</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This removes the task permanently. This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{deleteTaskError ? (
|
||||||
|
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-600">
|
||||||
|
{deleteTaskError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsDeleteDialogOpen(false)}
|
||||||
|
disabled={isDeletingTask}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteTask}
|
||||||
|
disabled={isDeletingTask}
|
||||||
|
className="bg-rose-600 text-white hover:bg-rose-700"
|
||||||
|
>
|
||||||
|
{isDeletingTask ? "Deleting…" : "Delete task"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
onOpenChange={(nextOpen) => {
|
onOpenChange={(nextOpen) => {
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ type Approval = {
|
|||||||
|
|
||||||
type BoardApprovalsPanelProps = {
|
type BoardApprovalsPanelProps = {
|
||||||
boardId: string;
|
boardId: string;
|
||||||
|
approvals?: Approval[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTimestamp = (value?: string | null) => {
|
const formatTimestamp = (value?: string | null) => {
|
||||||
@@ -51,14 +56,71 @@ const confidenceVariant = (confidence: number) => {
|
|||||||
return "warning";
|
return "warning";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
const humanizeAction = (value: string) =>
|
||||||
|
value
|
||||||
|
.split(".")
|
||||||
|
.map((part) =>
|
||||||
|
part
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||||
|
)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
const payloadValue = (payload: Approval["payload"], key: string) => {
|
||||||
|
if (!payload) return null;
|
||||||
|
const value = payload[key as keyof typeof payload];
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const approvalSummary = (approval: Approval) => {
|
||||||
|
const payload = approval.payload ?? {};
|
||||||
|
const taskId =
|
||||||
|
payloadValue(payload, "task_id") ??
|
||||||
|
payloadValue(payload, "taskId") ??
|
||||||
|
payloadValue(payload, "taskID");
|
||||||
|
const assignedAgentId =
|
||||||
|
payloadValue(payload, "assigned_agent_id") ??
|
||||||
|
payloadValue(payload, "assignedAgentId");
|
||||||
|
const reason = payloadValue(payload, "reason");
|
||||||
|
const title = payloadValue(payload, "title");
|
||||||
|
const role = payloadValue(payload, "role");
|
||||||
|
const isAssign = approval.action_type.includes("assign");
|
||||||
|
const rows: Array<{ label: string; value: string }> = [];
|
||||||
|
if (taskId) rows.push({ label: "Task", value: taskId });
|
||||||
|
if (isAssign) {
|
||||||
|
rows.push({
|
||||||
|
label: "Assignee",
|
||||||
|
value: assignedAgentId ?? "Unassigned",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (title) rows.push({ label: "Title", value: title });
|
||||||
|
if (role) rows.push({ label: "Role", value: role });
|
||||||
|
return { taskId, reason, rows };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BoardApprovalsPanel({
|
||||||
|
boardId,
|
||||||
|
approvals: externalApprovals,
|
||||||
|
isLoading: externalLoading,
|
||||||
|
error: externalError,
|
||||||
|
onRefresh,
|
||||||
|
onDecision,
|
||||||
|
}: BoardApprovalsPanelProps) {
|
||||||
const { getToken, isSignedIn } = useAuth();
|
const { getToken, isSignedIn } = useAuth();
|
||||||
const [approvals, setApprovals] = useState<Approval[]>([]);
|
const [internalApprovals, setInternalApprovals] = useState<Approval[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||||
|
const usingExternal = Array.isArray(externalApprovals);
|
||||||
|
const approvals = usingExternal ? externalApprovals ?? [] : internalApprovals;
|
||||||
|
const loadingState = usingExternal ? externalLoading ?? false : isLoading;
|
||||||
|
const errorState = usingExternal ? externalError ?? null : error;
|
||||||
|
|
||||||
const loadApprovals = useCallback(async () => {
|
const loadApprovals = useCallback(async () => {
|
||||||
|
if (usingExternal) return;
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -71,23 +133,29 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Unable to load approvals.");
|
if (!res.ok) throw new Error("Unable to load approvals.");
|
||||||
const data = (await res.json()) as Approval[];
|
const data = (await res.json()) as Approval[];
|
||||||
setApprovals(data);
|
setInternalApprovals(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Unable to load approvals.");
|
setError(err instanceof Error ? err.message : "Unable to load approvals.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [boardId, getToken, isSignedIn]);
|
}, [boardId, getToken, isSignedIn, usingExternal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (usingExternal) return;
|
||||||
loadApprovals();
|
loadApprovals();
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
const interval = setInterval(loadApprovals, 15000);
|
const interval = setInterval(loadApprovals, 15000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [boardId, isSignedIn, loadApprovals]);
|
}, [boardId, isSignedIn, loadApprovals, usingExternal]);
|
||||||
|
|
||||||
const handleDecision = useCallback(
|
const handleDecision = useCallback(
|
||||||
async (approvalId: string, status: "approved" | "rejected") => {
|
async (approvalId: string, status: "approved" | "rejected") => {
|
||||||
|
if (onDecision) {
|
||||||
|
onDecision(approvalId, status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (usingExternal) return;
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
setUpdatingId(approvalId);
|
setUpdatingId(approvalId);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -106,7 +174,7 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
|||||||
);
|
);
|
||||||
if (!res.ok) throw new Error("Unable to update approval.");
|
if (!res.ok) throw new Error("Unable to update approval.");
|
||||||
const updated = (await res.json()) as Approval;
|
const updated = (await res.json()) as Approval;
|
||||||
setApprovals((prev) =>
|
setInternalApprovals((prev) =>
|
||||||
prev.map((item) => (item.id === approvalId ? updated : item))
|
prev.map((item) => (item.id === approvalId ? updated : item))
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -117,19 +185,23 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
|||||||
setUpdatingId(null);
|
setUpdatingId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[boardId, getToken, isSignedIn]
|
[boardId, getToken, isSignedIn, onDecision, usingExternal]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortedApprovals = useMemo(() => {
|
const sortedApprovals = useMemo(() => {
|
||||||
const pending = approvals.filter((item) => item.status === "pending");
|
|
||||||
const resolved = approvals.filter((item) => item.status !== "pending");
|
|
||||||
const sortByTime = (items: Approval[]) =>
|
const sortByTime = (items: Approval[]) =>
|
||||||
[...items].sort((a, b) => {
|
[...items].sort((a, b) => {
|
||||||
const aTime = new Date(a.created_at).getTime();
|
const aTime = new Date(a.created_at).getTime();
|
||||||
const bTime = new Date(b.created_at).getTime();
|
const bTime = new Date(b.created_at).getTime();
|
||||||
return bTime - aTime;
|
return bTime - aTime;
|
||||||
});
|
});
|
||||||
return [...sortByTime(pending), ...sortByTime(resolved)];
|
const pending = sortByTime(
|
||||||
|
approvals.filter((item) => item.status === "pending")
|
||||||
|
);
|
||||||
|
const resolved = sortByTime(
|
||||||
|
approvals.filter((item) => item.status !== "pending")
|
||||||
|
);
|
||||||
|
return { pending, resolved };
|
||||||
}, [approvals]);
|
}, [approvals]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -141,10 +213,14 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
|||||||
Approvals
|
Approvals
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg font-semibold text-strong">
|
<p className="mt-1 text-lg font-semibold text-strong">
|
||||||
Pending decisions
|
{sortedApprovals.pending.length} pending
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" size="sm" onClick={loadApprovals}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRefresh ?? loadApprovals}
|
||||||
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,82 +229,179 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
|||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 pt-5">
|
<CardContent className="space-y-4 pt-5">
|
||||||
{error ? (
|
{errorState ? (
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
{error}
|
{errorState}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isLoading ? (
|
{loadingState ? (
|
||||||
<p className="text-sm text-muted">Loading approvals…</p>
|
<p className="text-sm text-muted">Loading approvals…</p>
|
||||||
) : sortedApprovals.length === 0 ? (
|
) : sortedApprovals.pending.length === 0 &&
|
||||||
|
sortedApprovals.resolved.length === 0 ? (
|
||||||
<p className="text-sm text-muted">No approvals yet.</p>
|
<p className="text-sm text-muted">No approvals yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{sortedApprovals.map((approval) => (
|
{sortedApprovals.pending.length > 0 ? (
|
||||||
<div
|
<div className="space-y-3">
|
||||||
key={approval.id}
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||||
className="space-y-2 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
|
Pending
|
||||||
>
|
</p>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
{sortedApprovals.pending.map((approval) => {
|
||||||
<div>
|
const summary = approvalSummary(approval);
|
||||||
<p className="text-sm font-semibold text-strong">
|
return (
|
||||||
{approval.action_type.replace(/_/g, " ")}
|
<div
|
||||||
</p>
|
key={approval.id}
|
||||||
<p className="text-xs text-muted">
|
className="space-y-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
|
||||||
Requested {formatTimestamp(approval.created_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant={confidenceVariant(approval.confidence)}>
|
|
||||||
{approval.confidence}% confidence
|
|
||||||
</Badge>
|
|
||||||
<Badge variant={statusBadgeVariant(approval.status)}>
|
|
||||||
{approval.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{approval.payload || approval.rubric_scores ? (
|
|
||||||
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted">
|
|
||||||
<summary className="cursor-pointer font-semibold text-strong">
|
|
||||||
Details
|
|
||||||
</summary>
|
|
||||||
{approval.payload ? (
|
|
||||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
|
||||||
Payload: {JSON.stringify(approval.payload, null, 2)}
|
|
||||||
</pre>
|
|
||||||
) : null}
|
|
||||||
{approval.rubric_scores ? (
|
|
||||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
|
||||||
Rubric: {JSON.stringify(approval.rubric_scores, null, 2)}
|
|
||||||
</pre>
|
|
||||||
) : null}
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
{approval.status === "pending" ? (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDecision(approval.id, "approved")}
|
|
||||||
disabled={updatingId === approval.id}
|
|
||||||
>
|
>
|
||||||
Approve
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
</Button>
|
<div>
|
||||||
<Button
|
<p className="text-sm font-semibold text-strong">
|
||||||
variant="outline"
|
{humanizeAction(approval.action_type)}
|
||||||
size="sm"
|
</p>
|
||||||
onClick={() => handleDecision(approval.id, "rejected")}
|
<p className="text-xs text-muted">
|
||||||
disabled={updatingId === approval.id}
|
Requested {formatTimestamp(approval.created_at)}
|
||||||
className={cn(
|
</p>
|
||||||
"border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]"
|
</div>
|
||||||
)}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
>
|
<Badge variant={confidenceVariant(approval.confidence)}>
|
||||||
Reject
|
{approval.confidence}% confidence
|
||||||
</Button>
|
</Badge>
|
||||||
</div>
|
<Badge variant={statusBadgeVariant(approval.status)}>
|
||||||
) : null}
|
{approval.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{summary.rows.length > 0 ? (
|
||||||
|
<div className="grid gap-2 text-sm text-strong sm:grid-cols-2">
|
||||||
|
{summary.rows.map((row) => (
|
||||||
|
<div key={`${approval.id}-${row.label}`}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||||
|
{row.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-strong">
|
||||||
|
{row.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{summary.reason ? (
|
||||||
|
<p className="text-sm text-muted">{summary.reason}</p>
|
||||||
|
) : null}
|
||||||
|
{approval.payload || approval.rubric_scores ? (
|
||||||
|
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted">
|
||||||
|
<summary className="cursor-pointer font-semibold text-strong">
|
||||||
|
Details
|
||||||
|
</summary>
|
||||||
|
{approval.payload ? (
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
||||||
|
Payload: {JSON.stringify(approval.payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
{approval.rubric_scores ? (
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
||||||
|
Rubric:{" "}
|
||||||
|
{JSON.stringify(approval.rubric_scores, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDecision(approval.id, "approved")}
|
||||||
|
disabled={updatingId === approval.id}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDecision(approval.id, "rejected")}
|
||||||
|
disabled={updatingId === approval.id}
|
||||||
|
className={cn(
|
||||||
|
"border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : null}
|
||||||
|
{sortedApprovals.resolved.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||||
|
Resolved
|
||||||
|
</p>
|
||||||
|
{sortedApprovals.resolved.map((approval) => {
|
||||||
|
const summary = approvalSummary(approval);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={approval.id}
|
||||||
|
className="space-y-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-strong">
|
||||||
|
{humanizeAction(approval.action_type)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Requested {formatTimestamp(approval.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant={confidenceVariant(approval.confidence)}>
|
||||||
|
{approval.confidence}% confidence
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={statusBadgeVariant(approval.status)}>
|
||||||
|
{approval.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{summary.rows.length > 0 ? (
|
||||||
|
<div className="grid gap-2 text-sm text-strong sm:grid-cols-2">
|
||||||
|
{summary.rows.map((row) => (
|
||||||
|
<div key={`${approval.id}-${row.label}`}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||||
|
{row.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-strong">
|
||||||
|
{row.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{summary.reason ? (
|
||||||
|
<p className="text-sm text-muted">{summary.reason}</p>
|
||||||
|
) : null}
|
||||||
|
{approval.payload || approval.rubric_scores ? (
|
||||||
|
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted">
|
||||||
|
<summary className="cursor-pointer font-semibold text-strong">
|
||||||
|
Details
|
||||||
|
</summary>
|
||||||
|
{approval.payload ? (
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
||||||
|
Payload: {JSON.stringify(approval.payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
{approval.rubric_scores ? (
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
||||||
|
Rubric:{" "}
|
||||||
|
{JSON.stringify(approval.rubric_scores, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,71 +4,53 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
interface TaskCardProps {
|
interface TaskCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
status: string;
|
priority?: string;
|
||||||
assignee?: string;
|
assignee?: string;
|
||||||
due?: string;
|
due?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
draggable?: boolean;
|
||||||
|
isDragging?: boolean;
|
||||||
|
onDragStart?: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDragEnd?: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskCard({
|
export function TaskCard({
|
||||||
title,
|
title,
|
||||||
status,
|
priority,
|
||||||
assignee,
|
assignee,
|
||||||
due,
|
due,
|
||||||
onClick,
|
onClick,
|
||||||
|
draggable = false,
|
||||||
|
isDragging = false,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
const statusConfig: Record<
|
const priorityBadge = (value?: string) => {
|
||||||
string,
|
if (!value) return null;
|
||||||
{ label: string; dot: string; badge: string; text: string }
|
const normalized = value.toLowerCase();
|
||||||
> = {
|
if (normalized === "high") {
|
||||||
inbox: {
|
return "bg-rose-100 text-rose-700";
|
||||||
label: "Inbox",
|
}
|
||||||
dot: "bg-slate-400",
|
if (normalized === "medium") {
|
||||||
badge: "bg-slate-100",
|
return "bg-amber-100 text-amber-700";
|
||||||
text: "text-slate-600",
|
}
|
||||||
},
|
if (normalized === "low") {
|
||||||
assigned: {
|
return "bg-emerald-100 text-emerald-700";
|
||||||
label: "Assigned",
|
}
|
||||||
dot: "bg-blue-500",
|
return "bg-slate-100 text-slate-600";
|
||||||
badge: "bg-blue-50",
|
|
||||||
text: "text-blue-700",
|
|
||||||
},
|
|
||||||
in_progress: {
|
|
||||||
label: "In progress",
|
|
||||||
dot: "bg-purple-500",
|
|
||||||
badge: "bg-purple-50",
|
|
||||||
text: "text-purple-700",
|
|
||||||
},
|
|
||||||
testing: {
|
|
||||||
label: "Testing",
|
|
||||||
dot: "bg-amber-500",
|
|
||||||
badge: "bg-amber-50",
|
|
||||||
text: "text-amber-700",
|
|
||||||
},
|
|
||||||
review: {
|
|
||||||
label: "Review",
|
|
||||||
dot: "bg-indigo-500",
|
|
||||||
badge: "bg-indigo-50",
|
|
||||||
text: "text-indigo-700",
|
|
||||||
},
|
|
||||||
done: {
|
|
||||||
label: "Done",
|
|
||||||
dot: "bg-green-500",
|
|
||||||
badge: "bg-green-50",
|
|
||||||
text: "text-green-700",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = statusConfig[status] ?? {
|
const priorityLabel = priority ? priority.toUpperCase() : "MEDIUM";
|
||||||
label: status,
|
|
||||||
dot: "bg-slate-400",
|
|
||||||
badge: "bg-slate-100",
|
|
||||||
text: "text-slate-600",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md"
|
className={cn(
|
||||||
|
"group cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md",
|
||||||
|
isDragging && "opacity-60 shadow-none",
|
||||||
|
)}
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -81,18 +63,16 @@ export function TaskCard({
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
|
||||||
config.badge,
|
|
||||||
config.text,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn("h-1.5 w-1.5 rounded-full", config.dot)} />
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
<p className="text-sm font-medium text-slate-900">{title}</p>
|
<p className="text-sm font-medium text-slate-900">{title}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
||||||
|
priorityBadge(priority) ?? "bg-slate-100 text-slate-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{priorityLabel}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { TaskCard } from "@/components/molecules/TaskCard";
|
import { TaskCard } from "@/components/molecules/TaskCard";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -21,6 +21,7 @@ type TaskBoardProps = {
|
|||||||
onCreateTask: () => void;
|
onCreateTask: () => void;
|
||||||
isCreateDisabled?: boolean;
|
isCreateDisabled?: boolean;
|
||||||
onTaskSelect?: (task: Task) => void;
|
onTaskSelect?: (task: Task) => void;
|
||||||
|
onTaskMove?: (taskId: string, status: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -30,6 +31,7 @@ const columns = [
|
|||||||
dot: "bg-slate-400",
|
dot: "bg-slate-400",
|
||||||
accent: "hover:border-slate-400 hover:bg-slate-50",
|
accent: "hover:border-slate-400 hover:bg-slate-50",
|
||||||
text: "group-hover:text-slate-700 text-slate-500",
|
text: "group-hover:text-slate-700 text-slate-500",
|
||||||
|
badge: "bg-slate-100 text-slate-600",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "In Progress",
|
title: "In Progress",
|
||||||
@@ -37,6 +39,7 @@ const columns = [
|
|||||||
dot: "bg-purple-500",
|
dot: "bg-purple-500",
|
||||||
accent: "hover:border-purple-400 hover:bg-purple-50",
|
accent: "hover:border-purple-400 hover:bg-purple-50",
|
||||||
text: "group-hover:text-purple-600 text-slate-500",
|
text: "group-hover:text-purple-600 text-slate-500",
|
||||||
|
badge: "bg-purple-100 text-purple-700",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Review",
|
title: "Review",
|
||||||
@@ -44,6 +47,7 @@ const columns = [
|
|||||||
dot: "bg-indigo-500",
|
dot: "bg-indigo-500",
|
||||||
accent: "hover:border-indigo-400 hover:bg-indigo-50",
|
accent: "hover:border-indigo-400 hover:bg-indigo-50",
|
||||||
text: "group-hover:text-indigo-600 text-slate-500",
|
text: "group-hover:text-indigo-600 text-slate-500",
|
||||||
|
badge: "bg-indigo-100 text-indigo-700",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Done",
|
title: "Done",
|
||||||
@@ -51,6 +55,7 @@ const columns = [
|
|||||||
dot: "bg-green-500",
|
dot: "bg-green-500",
|
||||||
accent: "hover:border-green-400 hover:bg-green-50",
|
accent: "hover:border-green-400 hover:bg-green-50",
|
||||||
text: "group-hover:text-green-600 text-slate-500",
|
text: "group-hover:text-green-600 text-slate-500",
|
||||||
|
badge: "bg-emerald-100 text-emerald-700",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -69,7 +74,11 @@ export function TaskBoard({
|
|||||||
onCreateTask,
|
onCreateTask,
|
||||||
isCreateDisabled = false,
|
isCreateDisabled = false,
|
||||||
onTaskSelect,
|
onTaskSelect,
|
||||||
|
onTaskMove,
|
||||||
}: TaskBoardProps) {
|
}: TaskBoardProps) {
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
|
const [activeColumn, setActiveColumn] = useState<string | null>(null);
|
||||||
|
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const buckets: Record<string, Task[]> = {};
|
const buckets: Record<string, Task[]> = {};
|
||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
@@ -82,12 +91,67 @@ export function TaskBoard({
|
|||||||
return buckets;
|
return buckets;
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
|
const handleDragStart =
|
||||||
|
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
setDraggingId(task.id);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
"text/plain",
|
||||||
|
JSON.stringify({ taskId: task.id, status: task.status }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggingId(null);
|
||||||
|
setActiveColumn(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop =
|
||||||
|
(status: string) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveColumn(null);
|
||||||
|
const raw = event.dataTransfer.getData("text/plain");
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(raw) as { taskId?: string; status?: string };
|
||||||
|
if (!payload.taskId || !payload.status) return;
|
||||||
|
if (payload.status === status) return;
|
||||||
|
onTaskMove?.(payload.taskId, status);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed payloads.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver =
|
||||||
|
(status: string) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (activeColumn !== status) {
|
||||||
|
setActiveColumn(status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave =
|
||||||
|
(status: string) => (_event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
if (activeColumn === status) {
|
||||||
|
setActiveColumn(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6">
|
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6">
|
||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const columnTasks = grouped[column.status] ?? [];
|
const columnTasks = grouped[column.status] ?? [];
|
||||||
return (
|
return (
|
||||||
<div key={column.title} className="kanban-column min-h-[calc(100vh-260px)]">
|
<div
|
||||||
|
key={column.title}
|
||||||
|
className={cn(
|
||||||
|
"kanban-column min-h-[calc(100vh-260px)]",
|
||||||
|
activeColumn === column.status && "ring-2 ring-slate-200",
|
||||||
|
)}
|
||||||
|
onDrop={handleDrop(column.status)}
|
||||||
|
onDragOver={handleDragOver(column.status)}
|
||||||
|
onDragLeave={handleDragLeave(column.status)}
|
||||||
|
>
|
||||||
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -96,37 +160,30 @@ export function TaskBoard({
|
|||||||
{column.title}
|
{column.title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
|
||||||
|
column.badge,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{columnTasks.length}
|
{columnTasks.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
||||||
{column.status === "inbox" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCreateTask}
|
|
||||||
disabled={isCreateDisabled}
|
|
||||||
className={cn(
|
|
||||||
"group mb-3 flex w-full items-center justify-center rounded-lg border-2 border-dashed border-slate-300 px-4 py-4 text-sm font-medium transition",
|
|
||||||
column.accent,
|
|
||||||
isCreateDisabled && "cursor-not-allowed opacity-60"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn("flex items-center gap-2", column.text)}>
|
|
||||||
<span className="text-sm font-medium">New task</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{columnTasks.map((task) => (
|
{columnTasks.map((task) => (
|
||||||
<TaskCard
|
<TaskCard
|
||||||
key={task.id}
|
key={task.id}
|
||||||
title={task.title}
|
title={task.title}
|
||||||
status={column.status}
|
priority={task.priority}
|
||||||
assignee={task.assignee}
|
assignee={task.assignee}
|
||||||
due={formatDueDate(task.due_at)}
|
due={formatDueDate(task.due_at)}
|
||||||
onClick={() => onTaskSelect?.(task)}
|
onClick={() => onTaskSelect?.(task)}
|
||||||
|
draggable
|
||||||
|
isDragging={draggingId === task.id}
|
||||||
|
onDragStart={handleDragStart(task)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ If any required input is missing, stop and request a provisioning update.
|
|||||||
- If workload or skills coverage is insufficient, create a new agent.
|
- If workload or skills coverage is insufficient, create a new agent.
|
||||||
- Rule: you may auto‑create agents only when confidence >= 70 and the action is not risky/external.
|
- Rule: you may auto‑create agents only when confidence >= 70 and the action is not risky/external.
|
||||||
- If risky/external or confidence < 70, create an approval instead.
|
- If risky/external or confidence < 70, create an approval instead.
|
||||||
|
- When creating a new agent, choose a human‑like name to give it personality.
|
||||||
Agent create (lead‑allowed):
|
Agent create (lead‑allowed):
|
||||||
POST $BASE_URL/api/v1/agent/agents
|
POST $BASE_URL/api/v1/agent/agents
|
||||||
Body example:
|
Body example:
|
||||||
|
|||||||
Reference in New Issue
Block a user