diff --git a/backend/app/api/board_memory.py b/backend/app/api/board_memory.py index fd7bc39e..ee9f6513 100644 --- a/backend/app/api/board_memory.py +++ b/backend/app/api/board_memory.py @@ -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" diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index e728e075..bb7960c9 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -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." diff --git a/backend/app/services/mentions.py b/backend/app/services/mentions.py new file mode 100644 index 00000000..db937dae --- /dev/null +++ b/backend/app/services/mentions.py @@ -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 diff --git a/backend/tests/test_mentions.py b/backend/tests/test_mentions.py new file mode 100644 index 00000000..739a8e31 --- /dev/null +++ b/backend/tests/test_mentions.py @@ -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 + diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index 2d740686..300e89e2 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -67,7 +67,6 @@ export default function EditBoardPage() { const [error, setError] = useState(null); const [metricsError, setMetricsError] = useState(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"); diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 5aa196f2..8d7f33aa 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -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 }) => ( +

+ ), + ul: ({ node: _node, className, ...props }) => ( +