feat: enhance collaboration guidelines in documentation and refactor mention handling for improved clarity and functionality
This commit is contained in:
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
@@ -26,11 +25,10 @@ from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.services.mentions import extract_mentions, matches_agent_mention
|
||||
|
||||
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:
|
||||
@@ -52,23 +50,6 @@ 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
|
||||
|
||||
|
||||
async def _gateway_config(session: AsyncSession, board: Board) -> GatewayClientConfig | None:
|
||||
if board.gateway_id is None:
|
||||
return None
|
||||
@@ -123,14 +104,14 @@ async def _notify_chat_targets(
|
||||
config = await _gateway_config(session, board)
|
||||
if config is None:
|
||||
return
|
||||
mentions = _extract_mentions(memory.content)
|
||||
mentions = extract_mentions(memory.content)
|
||||
statement = select(Agent).where(col(Agent.board_id) == board.id)
|
||||
targets: dict[str, Agent] = {}
|
||||
for agent in await session.exec(statement):
|
||||
if agent.is_board_lead:
|
||||
targets[str(agent.id)] = agent
|
||||
continue
|
||||
if mentions and _matches_mention(agent, mentions):
|
||||
if mentions and matches_agent_mention(agent, mentions):
|
||||
targets[str(agent.id)] = agent
|
||||
if actor.actor_type == "agent" and actor.agent:
|
||||
targets.pop(str(actor.agent.id), None)
|
||||
@@ -148,7 +129,7 @@ async def _notify_chat_targets(
|
||||
for agent in targets.values():
|
||||
if not agent.openclaw_session_id:
|
||||
continue
|
||||
mentioned = _matches_mention(agent, mentions)
|
||||
mentioned = matches_agent_mention(agent, mentions)
|
||||
header = "BOARD CHAT MENTION" if mentioned else "BOARD CHAT"
|
||||
message = (
|
||||
f"{header}\n"
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timezone
|
||||
@@ -40,6 +39,7 @@ from app.schemas.common import OkResponse
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
||||
from app.services.activity_log import record_activity
|
||||
from app.services.mentions import extract_mentions, matches_agent_mention
|
||||
|
||||
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
|
||||
|
||||
@@ -51,7 +51,6 @@ TASK_EVENT_TYPES = {
|
||||
"task.comment",
|
||||
}
|
||||
SSE_SEEN_MAX = 2000
|
||||
MENTION_PATTERN = re.compile(r"@([A-Za-z][\w-]{0,31})")
|
||||
|
||||
|
||||
def _comment_validation_error() -> HTTPException:
|
||||
@@ -99,23 +98,6 @@ def _parse_since(value: str | None) -> datetime | None:
|
||||
return parsed
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def _lead_was_mentioned(
|
||||
session: AsyncSession,
|
||||
task: Task,
|
||||
@@ -130,8 +112,8 @@ async def _lead_was_mentioned(
|
||||
for message in await session.exec(statement):
|
||||
if not message:
|
||||
continue
|
||||
mentions = _extract_mentions(message)
|
||||
if _matches_mention(lead, mentions):
|
||||
mentions = extract_mentions(message)
|
||||
if matches_agent_mention(lead, mentions):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -527,7 +509,7 @@ async def update_task(
|
||||
if updates["status"] == "inbox":
|
||||
task.assigned_agent_id = None
|
||||
task.in_progress_at = None
|
||||
task.status = updates["status"]
|
||||
task.status = updates["status"]
|
||||
task.updated_at = utcnow()
|
||||
session.add(task)
|
||||
if task.status != previous_status:
|
||||
@@ -718,12 +700,12 @@ async def create_task_comment(
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
await session.refresh(event)
|
||||
mention_names = _extract_mentions(payload.message)
|
||||
mention_names = extract_mentions(payload.message)
|
||||
targets: dict[UUID, Agent] = {}
|
||||
if mention_names and task.board_id:
|
||||
statement = select(Agent).where(col(Agent.board_id) == task.board_id)
|
||||
for agent in await session.exec(statement):
|
||||
if _matches_mention(agent, mention_names):
|
||||
if matches_agent_mention(agent, mention_names):
|
||||
targets[agent.id] = agent
|
||||
if not mention_names and task.assigned_agent_id:
|
||||
assigned_agent = await session.get(Agent, task.assigned_agent_id)
|
||||
@@ -742,7 +724,7 @@ async def create_task_comment(
|
||||
for agent in targets.values():
|
||||
if not agent.openclaw_session_id:
|
||||
continue
|
||||
mentioned = _matches_mention(agent, mention_names)
|
||||
mentioned = matches_agent_mention(agent, mention_names)
|
||||
header = "TASK MENTION" if mentioned else "NEW TASK COMMENT"
|
||||
action_line = (
|
||||
"You were mentioned in this comment."
|
||||
|
||||
34
backend/app/services/mentions.py
Normal file
34
backend/app/services/mentions.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from app.models.agents import Agent
|
||||
|
||||
# Mention tokens are single, space-free words (e.g. "@alex", "@lead").
|
||||
MENTION_PATTERN = re.compile(r"@([A-Za-z][\w-]{0,31})")
|
||||
|
||||
|
||||
def extract_mentions(message: str) -> set[str]:
|
||||
return {match.group(1).lower() for match in MENTION_PATTERN.finditer(message)}
|
||||
|
||||
|
||||
def matches_agent_mention(agent: Agent, mentions: set[str]) -> bool:
|
||||
if not mentions:
|
||||
return False
|
||||
|
||||
# "@lead" is a reserved shortcut that always targets the board lead.
|
||||
if "lead" in mentions and agent.is_board_lead:
|
||||
return True
|
||||
mentions = mentions - {"lead"}
|
||||
|
||||
name = (agent.name or "").strip()
|
||||
if not name:
|
||||
return False
|
||||
|
||||
normalized = name.lower()
|
||||
if normalized in mentions:
|
||||
return True
|
||||
|
||||
# Mentions are single tokens; match on first name for display names with spaces.
|
||||
first = normalized.split()[0]
|
||||
return first in mentions
|
||||
20
backend/tests/test_mentions.py
Normal file
20
backend/tests/test_mentions.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from app.models.agents import Agent
|
||||
from app.services.mentions import extract_mentions, matches_agent_mention
|
||||
|
||||
|
||||
def test_extract_mentions_parses_tokens():
|
||||
assert extract_mentions("hi @Alex and @bob-2") == {"alex", "bob-2"}
|
||||
|
||||
|
||||
def test_matches_agent_mention_matches_first_name():
|
||||
agent = Agent(name="Alice Cooper")
|
||||
assert matches_agent_mention(agent, {"alice"}) is True
|
||||
assert matches_agent_mention(agent, {"cooper"}) is False
|
||||
|
||||
|
||||
def test_matches_agent_mention_supports_reserved_lead_shortcut():
|
||||
lead = Agent(name="Riya", is_board_lead=True)
|
||||
other = Agent(name="Lead", is_board_lead=False)
|
||||
assert matches_agent_mention(lead, {"lead"}) is True
|
||||
assert matches_agent_mention(other, {"lead"}) is False
|
||||
|
||||
@@ -67,7 +67,6 @@ export default function EditBoardPage() {
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [metricsError, setMetricsError] = useState<string | null>(null);
|
||||
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
|
||||
|
||||
const onboardingParam = searchParams.get("onboarding");
|
||||
const searchParamsString = searchParams.toString();
|
||||
@@ -77,12 +76,12 @@ export default function EditBoardPage() {
|
||||
onboardingParam !== "0" &&
|
||||
onboardingParam.toLowerCase() !== "false";
|
||||
|
||||
const [isOnboardingOpen, setIsOnboardingOpen] = useState(shouldAutoOpenOnboarding);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardId) return;
|
||||
if (!shouldAutoOpenOnboarding) return;
|
||||
|
||||
setIsOnboardingOpen(true);
|
||||
|
||||
// Remove the flag from the URL so refreshes don't constantly reopen it.
|
||||
const nextParams = new URLSearchParams(searchParamsString);
|
||||
nextParams.delete("onboarding");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||
@@ -168,6 +168,178 @@ const MARKDOWN_TABLE_COMPONENTS: Components = {
|
||||
),
|
||||
};
|
||||
|
||||
const MARKDOWN_COMPONENTS_BASIC: Components = {
|
||||
...MARKDOWN_TABLE_COMPONENTS,
|
||||
p: ({ node: _node, className, ...props }) => (
|
||||
<p className={cn("mb-2 last:mb-0", className)} {...props} />
|
||||
),
|
||||
ul: ({ node: _node, className, ...props }) => (
|
||||
<ul className={cn("mb-2 list-disc pl-5", className)} {...props} />
|
||||
),
|
||||
ol: ({ node: _node, className, ...props }) => (
|
||||
<ol className={cn("mb-2 list-decimal pl-5", className)} {...props} />
|
||||
),
|
||||
li: ({ node: _node, className, ...props }) => (
|
||||
<li className={cn("mb-1", className)} {...props} />
|
||||
),
|
||||
strong: ({ node: _node, className, ...props }) => (
|
||||
<strong className={cn("font-semibold", className)} {...props} />
|
||||
),
|
||||
};
|
||||
|
||||
const MARKDOWN_COMPONENTS_DESCRIPTION: Components = {
|
||||
...MARKDOWN_COMPONENTS_BASIC,
|
||||
p: ({ node: _node, className, ...props }) => (
|
||||
<p className={cn("mb-3 last:mb-0", className)} {...props} />
|
||||
),
|
||||
h1: ({ node: _node, className, ...props }) => (
|
||||
<h1 className={cn("mb-2 text-base font-semibold", className)} {...props} />
|
||||
),
|
||||
h2: ({ node: _node, className, ...props }) => (
|
||||
<h2 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
|
||||
),
|
||||
h3: ({ node: _node, className, ...props }) => (
|
||||
<h3 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
|
||||
),
|
||||
code: ({ node: _node, className, ...props }) => (
|
||||
<code
|
||||
className={cn("rounded bg-slate-100 px-1 py-0.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
pre: ({ node: _node, className, ...props }) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const MARKDOWN_REMARK_PLUGINS_BASIC = [remarkGfm];
|
||||
const MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [remarkGfm, remarkBreaks];
|
||||
|
||||
type MarkdownVariant = "basic" | "comment" | "description";
|
||||
|
||||
const Markdown = memo(function Markdown({
|
||||
content,
|
||||
variant,
|
||||
}: {
|
||||
content: string;
|
||||
variant: MarkdownVariant;
|
||||
}) {
|
||||
const trimmed = content.trim();
|
||||
const remarkPlugins =
|
||||
variant === "comment"
|
||||
? MARKDOWN_REMARK_PLUGINS_WITH_BREAKS
|
||||
: MARKDOWN_REMARK_PLUGINS_BASIC;
|
||||
const components =
|
||||
variant === "description" ? MARKDOWN_COMPONENTS_DESCRIPTION : MARKDOWN_COMPONENTS_BASIC;
|
||||
return (
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
|
||||
{trimmed}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
|
||||
Markdown.displayName = "Markdown";
|
||||
|
||||
const formatShortTimestamp = (value: string) => {
|
||||
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 TaskCommentCard = memo(function TaskCommentCard({
|
||||
comment,
|
||||
authorLabel,
|
||||
}: {
|
||||
comment: TaskComment;
|
||||
authorLabel: string;
|
||||
}) {
|
||||
const message = (comment.message ?? "").trim();
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>{authorLabel}</span>
|
||||
<span>{formatShortTimestamp(comment.created_at)}</span>
|
||||
</div>
|
||||
{message ? (
|
||||
<div className="mt-2 select-text cursor-text text-sm leading-relaxed text-slate-900 break-words">
|
||||
<Markdown content={message} variant="comment" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-900">—</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TaskCommentCard.displayName = "TaskCommentCard";
|
||||
|
||||
const ChatMessageCard = memo(function ChatMessageCard({
|
||||
message,
|
||||
}: {
|
||||
message: BoardChatMessage;
|
||||
}) {
|
||||
return (
|
||||
<div 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">
|
||||
{formatShortTimestamp(message.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 select-text cursor-text text-sm leading-relaxed text-slate-900 break-words">
|
||||
<Markdown content={message.content} variant="basic" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ChatMessageCard.displayName = "ChatMessageCard";
|
||||
|
||||
const LiveFeedCard = memo(function LiveFeedCard({
|
||||
comment,
|
||||
taskTitle,
|
||||
authorLabel,
|
||||
}: {
|
||||
comment: TaskComment;
|
||||
taskTitle: string;
|
||||
authorLabel: string;
|
||||
}) {
|
||||
const message = (comment.message ?? "").trim();
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
||||
<div className="flex items-start justify-between gap-3 text-xs text-slate-500">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold text-slate-700">{taskTitle}</p>
|
||||
<p className="mt-1 text-[11px] text-slate-400">{authorLabel}</p>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{formatShortTimestamp(comment.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{message ? (
|
||||
<div className="mt-2 select-text cursor-text text-xs leading-relaxed text-slate-900 break-words">
|
||||
<Markdown content={message} variant="basic" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-slate-500">—</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LiveFeedCard.displayName = "LiveFeedCard";
|
||||
|
||||
export default function BoardDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
@@ -181,6 +353,7 @@ export default function BoardDetailPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const selectedTaskIdRef = useRef<string | null>(null);
|
||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
|
||||
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
||||
@@ -352,6 +525,10 @@ export default function BoardDetailPage() {
|
||||
agentsRef.current = agents;
|
||||
}, [agents]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedTaskIdRef.current = selectedTask?.id ?? null;
|
||||
}, [selectedTask?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
chatMessagesRef.current = chatMessages;
|
||||
}, [chatMessages]);
|
||||
@@ -691,14 +868,30 @@ export default function BoardDetailPage() {
|
||||
if (payload.comment?.task_id && payload.type === "task.comment") {
|
||||
pushLiveFeed(payload.comment);
|
||||
setComments((prev) => {
|
||||
if (selectedTask?.id !== payload.comment?.task_id) {
|
||||
if (selectedTaskIdRef.current !== payload.comment?.task_id) {
|
||||
return prev;
|
||||
}
|
||||
const exists = prev.some((item) => item.id === payload.comment?.id);
|
||||
if (exists) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, payload.comment as TaskComment];
|
||||
const createdAt = payload.comment?.created_at;
|
||||
const createdMs = createdAt ? new Date(createdAt).getTime() : NaN;
|
||||
if (prev.length === 0 || Number.isNaN(createdMs)) {
|
||||
return [...prev, payload.comment as TaskComment];
|
||||
}
|
||||
const last = prev[prev.length - 1];
|
||||
const lastMs = last?.created_at ? new Date(last.created_at).getTime() : NaN;
|
||||
if (!Number.isNaN(lastMs) && createdMs >= lastMs) {
|
||||
return [...prev, payload.comment as TaskComment];
|
||||
}
|
||||
const next = [...prev, payload.comment as TaskComment];
|
||||
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;
|
||||
});
|
||||
} else if (payload.task) {
|
||||
setTasks((prev) => {
|
||||
@@ -766,7 +959,7 @@ export default function BoardDetailPage() {
|
||||
window.clearTimeout(reconnectTimeout);
|
||||
}
|
||||
};
|
||||
}, [board, boardId, isSignedIn, selectedTask?.id, pushLiveFeed]);
|
||||
}, [board, boardId, isSignedIn, pushLiveFeed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSignedIn || !boardId) return;
|
||||
@@ -1007,14 +1200,6 @@ export default function BoardDetailPage() {
|
||||
selectedTask,
|
||||
]);
|
||||
|
||||
const orderedComments = useMemo(() => {
|
||||
return [...comments].sort((a, b) => {
|
||||
const aTime = new Date(a.created_at).getTime();
|
||||
const bTime = new Date(b.created_at).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
}, [comments]);
|
||||
|
||||
const pendingApprovals = useMemo(
|
||||
() => approvals.filter((approval) => approval.status === "pending"),
|
||||
[approvals],
|
||||
@@ -1061,7 +1246,13 @@ export default function BoardDetailPage() {
|
||||
taskId,
|
||||
);
|
||||
if (result.status !== 200) throw new Error("Unable to load comments.");
|
||||
setComments(result.data.items ?? []);
|
||||
const items = [...(result.data.items ?? [])];
|
||||
items.sort((a, b) => {
|
||||
const aTime = new Date(a.created_at).getTime();
|
||||
const bTime = new Date(b.created_at).getTime();
|
||||
return aTime - bTime;
|
||||
});
|
||||
setComments(items);
|
||||
} catch (err) {
|
||||
setCommentsError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
@@ -1074,6 +1265,7 @@ export default function BoardDetailPage() {
|
||||
setIsLiveFeedOpen(false);
|
||||
const fullTask = tasksRef.current.find((item) => item.id === task.id);
|
||||
if (!fullTask) return;
|
||||
selectedTaskIdRef.current = fullTask.id;
|
||||
setSelectedTask(fullTask);
|
||||
setIsDetailOpen(true);
|
||||
void loadComments(task.id);
|
||||
@@ -1081,6 +1273,7 @@ export default function BoardDetailPage() {
|
||||
|
||||
const closeComments = () => {
|
||||
setIsDetailOpen(false);
|
||||
selectedTaskIdRef.current = null;
|
||||
setSelectedTask(null);
|
||||
setComments([]);
|
||||
setCommentsError(null);
|
||||
@@ -1313,17 +1506,6 @@ export default function BoardDetailPage() {
|
||||
return "Agent";
|
||||
};
|
||||
|
||||
const formatCommentTimestamp = (value: string) => {
|
||||
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 formatTaskTimestamp = (value?: string | null) => {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
@@ -1792,44 +1974,7 @@ export default function BoardDetailPage() {
|
||||
</p>
|
||||
{selectedTask?.description ? (
|
||||
<div className="prose prose-sm max-w-none text-slate-700">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
...MARKDOWN_TABLE_COMPONENTS,
|
||||
p: ({ node: _node, ...props }) => (
|
||||
<p className="mb-3 last:mb-0" {...props} />
|
||||
),
|
||||
ul: ({ node: _node, ...props }) => (
|
||||
<ul className="mb-3 list-disc pl-5" {...props} />
|
||||
),
|
||||
ol: ({ node: _node, ...props }) => (
|
||||
<ol className="mb-3 list-decimal pl-5" {...props} />
|
||||
),
|
||||
li: ({ node: _node, ...props }) => (
|
||||
<li className="mb-1" {...props} />
|
||||
),
|
||||
strong: ({ node: _node, ...props }) => (
|
||||
<strong className="font-semibold" {...props} />
|
||||
),
|
||||
h1: ({ node: _node, ...props }) => (
|
||||
<h1 className="mb-2 text-base font-semibold" {...props} />
|
||||
),
|
||||
h2: ({ node: _node, ...props }) => (
|
||||
<h2 className="mb-2 text-sm font-semibold" {...props} />
|
||||
),
|
||||
h3: ({ node: _node, ...props }) => (
|
||||
<h3 className="mb-2 text-sm font-semibold" {...props} />
|
||||
),
|
||||
code: ({ node: _node, ...props }) => (
|
||||
<code className="rounded bg-slate-100 px-1 py-0.5 text-xs" {...props} />
|
||||
),
|
||||
pre: ({ node: _node, ...props }) => (
|
||||
<pre className="overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{selectedTask.description}
|
||||
</ReactMarkdown>
|
||||
<Markdown content={selectedTask.description} variant="description" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">No description provided.</p>
|
||||
@@ -1963,60 +2108,16 @@ export default function BoardDetailPage() {
|
||||
<p className="text-sm text-slate-500">No comments yet.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{orderedComments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="rounded-xl border border-slate-200 bg-white p-3 select-none"
|
||||
>
|
||||
<>
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>
|
||||
{comment.agent_id
|
||||
? assigneeById.get(comment.agent_id) ?? "Agent"
|
||||
: "Admin"}
|
||||
</span>
|
||||
<span>{formatCommentTimestamp(comment.created_at)}</span>
|
||||
</div>
|
||||
{comment.message?.trim() ? (
|
||||
<div className="mt-2 select-text cursor-text text-sm leading-relaxed text-slate-900 break-words">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={{
|
||||
...MARKDOWN_TABLE_COMPONENTS,
|
||||
p: ({ node: _node, ...props }) => (
|
||||
<p
|
||||
className="text-sm text-slate-900 break-words"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node: _node, ...props }) => (
|
||||
<ul
|
||||
className="list-disc pl-5 text-sm text-slate-900 break-words"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ node: _node, ...props }) => (
|
||||
<li
|
||||
className="mb-1 text-sm text-slate-900 break-words"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
strong: ({ node: _node, ...props }) => (
|
||||
<strong
|
||||
className="font-semibold text-slate-900"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{comment.message}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-900">—</p>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
{comments.map((comment) => (
|
||||
<TaskCommentCard
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
authorLabel={
|
||||
comment.agent_id
|
||||
? assigneeById.get(comment.agent_id) ?? "Agent"
|
||||
: "Admin"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -2063,41 +2164,7 @@ export default function BoardDetailPage() {
|
||||
</p>
|
||||
) : (
|
||||
chatMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4 select-none"
|
||||
>
|
||||
<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 select-text cursor-text text-sm leading-relaxed text-slate-900">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
...MARKDOWN_TABLE_COMPONENTS,
|
||||
p: ({ node: _node, ...props }) => (
|
||||
<p className="mb-2 last:mb-0" {...props} />
|
||||
),
|
||||
ul: ({ node: _node, ...props }) => (
|
||||
<ul className="mb-2 list-disc pl-5" {...props} />
|
||||
),
|
||||
ol: ({ node: _node, ...props }) => (
|
||||
<ol className="mb-2 list-decimal pl-5" {...props} />
|
||||
),
|
||||
strong: ({ node: _node, ...props }) => (
|
||||
<strong className="font-semibold" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<ChatMessageCard key={message.id} message={message} />
|
||||
))
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
@@ -2140,57 +2207,20 @@ export default function BoardDetailPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{orderedLiveFeed.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="rounded-xl border border-slate-200 bg-white p-3 select-none"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 text-xs text-slate-500">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold text-slate-700">
|
||||
{comment.task_id
|
||||
? taskTitleById.get(comment.task_id) ?? "Task"
|
||||
: "Task"}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-slate-400">
|
||||
{comment.agent_id
|
||||
? assigneeById.get(comment.agent_id) ?? "Agent"
|
||||
: "Admin"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{formatCommentTimestamp(comment.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{comment.message?.trim() ? (
|
||||
<div className="mt-2 select-text cursor-text text-xs leading-relaxed text-slate-900">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
...MARKDOWN_TABLE_COMPONENTS,
|
||||
p: ({ node: _node, ...props }) => (
|
||||
<p className="mb-2 last:mb-0" {...props} />
|
||||
),
|
||||
ul: ({ node: _node, ...props }) => (
|
||||
<ul className="mb-2 list-disc pl-5" {...props} />
|
||||
),
|
||||
ol: ({ node: _node, ...props }) => (
|
||||
<ol className="mb-2 list-decimal pl-5" {...props} />
|
||||
),
|
||||
li: ({ node: _node, ...props }) => (
|
||||
<li className="mb-1" {...props} />
|
||||
),
|
||||
strong: ({ node: _node, ...props }) => (
|
||||
<strong className="font-semibold" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{comment.message}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-slate-500">—</p>
|
||||
)}
|
||||
</div>
|
||||
<LiveFeedCard
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
taskTitle={
|
||||
comment.task_id
|
||||
? taskTitleById.get(comment.task_id) ?? "Task"
|
||||
: "Task"
|
||||
}
|
||||
authorLabel={
|
||||
comment.agent_id
|
||||
? assigneeById.get(comment.agent_id) ?? "Agent"
|
||||
: "Admin"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,17 @@ Write things down. Do not rely on short-term context.
|
||||
- HEARTBEAT.md defines what to do on each heartbeat.
|
||||
- Follow it exactly.
|
||||
|
||||
## Collaboration (mandatory)
|
||||
- You are one of multiple agents on a board. Act like a team, not a silo.
|
||||
- The assigned agent is the DRI for a task. Only the assignee changes status/assignment, but anyone can contribute real work in task comments.
|
||||
- Task comments are the primary channel for agent-to-agent collaboration.
|
||||
- Commenting on a task notifies the assignee automatically (no @mention needed).
|
||||
- Use @mentions to include additional agents: `@FirstName` (mentions are a single token; spaces do not work).
|
||||
- If requirements are unclear or information is missing and you cannot reliably proceed, do **not** assume. Ask the board lead for clarity by tagging them.
|
||||
- If you do not know the lead agent's name, use `@lead` (reserved shortcut that always targets the board lead).
|
||||
- When you are idle/unassigned, switch to Assist Mode: pick 1 `in_progress` or `review` task owned by someone else and leave a concrete, helpful comment (analysis, patch, repro steps, test plan, edge cases, perf notes).
|
||||
- Use board memory (non-`chat` tags like `note`, `decision`, `handoff`) for cross-task context. Do not put task status updates there.
|
||||
|
||||
## Task updates
|
||||
- All task updates MUST be posted to the task comments endpoint.
|
||||
- Do not post task updates in chat/web channels under any circumstance.
|
||||
|
||||
@@ -24,6 +24,8 @@ If any required input is missing, stop and request a provisioning update.
|
||||
- Every status change must have a comment within 30 seconds.
|
||||
- Do not claim a new task if you already have one in progress.
|
||||
- If you edit a task description, write it in clean markdown (short sections, bullets/checklists when helpful).
|
||||
- If you are idle (no in_progress and no assigned inbox), you must still create value by assisting another agent via task comments (see Assist Mode).
|
||||
- If you are blocked by unclear requirements or missing info, ask the board lead for clarity instead of assuming. Tag them as `@FirstName` or use `@lead` if you don't know the name.
|
||||
|
||||
## 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.
|
||||
@@ -64,6 +66,12 @@ curl -s "$BASE_URL/api/v1/agent/boards" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
|
||||
2b) List agents on the board (so you know who to collaborate with and who is lead):
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/agent/agents?board_id=$BOARD_ID" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
|
||||
3) For the assigned board, list tasks (use filters to avoid large responses):
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&assigned_agent_id=$AGENT_ID&limit=5" \
|
||||
@@ -82,7 +90,7 @@ curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=
|
||||
|
||||
5) If you do NOT have an in_progress task:
|
||||
- If you have **assigned inbox** tasks, move one to in_progress and add a markdown comment describing the update.
|
||||
- If there are **unassigned inbox** tasks, do **not** claim them. Wait for the board lead to assign work.
|
||||
- If you have **no assigned inbox** tasks, do **not** claim unassigned work. Run Assist Mode (below).
|
||||
|
||||
6) Work the task:
|
||||
- Post progress comments as you go.
|
||||
@@ -112,6 +120,38 @@ curl -s -X PATCH "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}" \
|
||||
-d '{"status": "review"}'
|
||||
```
|
||||
|
||||
## Assist Mode (when idle)
|
||||
If you have no in_progress task and no assigned inbox tasks, you still must contribute on every heartbeat by helping another agent.
|
||||
|
||||
1) List tasks to assist (pick 1 in_progress or review task you can add value to):
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=in_progress&limit=50" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=review&limit=50" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
|
||||
2) Read the task comments:
|
||||
```bash
|
||||
curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}/comments?limit=50" \
|
||||
-H "X-Agent-Token: {{ auth_token }}"
|
||||
```
|
||||
|
||||
3) Leave a concrete, helpful comment in the task thread (this notifies the assignee automatically):
|
||||
```bash
|
||||
curl -s -X POST "$BASE_URL/api/v1/agent/boards/$BOARD_ID/tasks/$TASK_ID/comments" \
|
||||
-H "X-Agent-Token: {{ auth_token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message":"### Assist\n- What I found\n- Suggested fix\n- Edge cases/tests\n\n### Next\n- Recommended next step"}'
|
||||
```
|
||||
|
||||
Constraints:
|
||||
- Do not change task status or assignment (you are not the DRI).
|
||||
- Do not spam. Default to 1 assist comment per heartbeat.
|
||||
- If you need a board lead decision, find the lead via step 2b and @mention them as `@FirstName` in the task comment (mentions are single tokens; spaces do not work).
|
||||
|
||||
## Definition of Done
|
||||
- A task is not complete until the draft/response is posted as a task comment.
|
||||
- Comments must be markdown.
|
||||
|
||||
@@ -25,18 +25,22 @@ If any required input is missing, stop and request a provisioning update.
|
||||
- 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.
|
||||
- **Never idle.** If there are no pending tasks (no inbox / in_progress / review items), you must create a concrete plan and populate the board with the next best tasks to achieve the goal.
|
||||
- You are responsible for **increasing collaboration among other agents**. Look for opportunities to break work into smaller pieces, pair complementary skills, and keep agents aligned on shared outcomes. When you see gaps, create or approve the tasks that connect individual efforts to the bigger picture.
|
||||
- Prefer "Assist" tasks over reassigning. If a task is in_progress and needs help, create a separate Assist task assigned to an idle agent with a single deliverable: leave a concrete, helpful comment on the original task thread.
|
||||
- Ensure every high-priority task has a second set of eyes: a buddy agent for review, validation, or edge-case testing (again via Assist tasks).
|
||||
- When you leave review feedback, format it as clean markdown. Use headings/bullets/tables when helpful, but only when it improves clarity.
|
||||
- If your feedback is longer than 2 sentences, do **not** write a single paragraph. Use a short heading + bullets so each idea is on its own line.
|
||||
|
||||
## Task mentions
|
||||
- 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.
|
||||
- `@lead` is a reserved shortcut mention that always refers to you (the board lead). Treat it as high priority.
|
||||
|
||||
## 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.
|
||||
- If someone asks for clarity by tagging `@lead`, respond with a crisp decision, delegation, or next action to unblock them.
|
||||
|
||||
## Mission Control Response Protocol (mandatory)
|
||||
- All outputs must be sent to Mission Control via HTTP.
|
||||
@@ -61,6 +65,7 @@ If any required input is missing, stop and request a provisioning update.
|
||||
2) Review recent tasks/comments and board memory:
|
||||
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?limit=50
|
||||
- GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/memory?limit=50
|
||||
- GET $BASE_URL/api/v1/agent/agents?board_id={BOARD_ID}
|
||||
- For any task in **review**, fetch its comments:
|
||||
GET $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}/comments
|
||||
|
||||
@@ -86,6 +91,11 @@ If any required input is missing, stop and request a provisioning update.
|
||||
PATCH $BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks/{TASK_ID}
|
||||
Body: {"assigned_agent_id":"AGENT_ID"}
|
||||
|
||||
5b) Build collaboration pairs:
|
||||
- For each high/medium priority task in_progress, ensure there is at least one helper agent.
|
||||
- If a task needs help, create a new Assist task assigned to an idle agent with a clear deliverable: "leave a helpful comment on TASK_ID with analysis/patch/tests".
|
||||
- If you notice duplication between tasks, create a coordination task to split scope cleanly and assign it to one agent.
|
||||
|
||||
6) Create agents only when needed:
|
||||
- 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.
|
||||
@@ -95,7 +105,7 @@ If any required input is missing, stop and request a provisioning update.
|
||||
POST $BASE_URL/api/v1/agent/agents
|
||||
Body example:
|
||||
{
|
||||
"name": "Researcher Alpha",
|
||||
"name": "Riya",
|
||||
"board_id": "{BOARD_ID}",
|
||||
"identity_profile": {
|
||||
"role": "Research",
|
||||
@@ -219,5 +229,5 @@ curl -s "$BASE_URL/api/v1/agent/boards/{BOARD_ID}/tasks?status=inbox&unassigned=
|
||||
- Claiming or working tasks as the lead.
|
||||
- Posting task comments outside review, @mentions, or tasks you created.
|
||||
- Assigning a task to yourself.
|
||||
- Marking tasks review/done (lead cannot).
|
||||
- Moving tasks to in_progress/review (lead cannot).
|
||||
- Using non‑agent endpoints or Authorization header.
|
||||
|
||||
Reference in New Issue
Block a user