From 6cb5702a2b4e04961cbff71992e64a35ebdd92be Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Thu, 12 Feb 2026 21:46:22 +0530
Subject: [PATCH] feat: enhance task management with due date handling and
mention support
---
backend/app/api/metrics.py | 45 ++-
backend/tests/test_error_handling.py | 15 +
backend/tests/test_metrics_filters.py | 128 ++++++++
.../src/api/generated/model/approvalCreate.ts | 4 +
.../src/api/generated/model/approvalRead.ts | 5 +
...rdMetricsApiV1MetricsDashboardGetParams.ts | 2 +
.../src/app/board-groups/[groupId]/page.tsx | 22 +-
frontend/src/app/boards/[boardId]/page.tsx | 104 ++++---
frontend/src/app/dashboard/page.tsx | 180 ++++++++++-
frontend/src/components/BoardChatComposer.tsx | 226 +++++++++++++-
frontend/src/components/atoms/Markdown.tsx | 5 +-
.../src/components/molecules/TaskCard.tsx | 16 +-
.../src/components/organisms/TaskBoard.tsx | 294 +++++++++---------
13 files changed, 843 insertions(+), 203 deletions(-)
create mode 100644 backend/tests/test_metrics_filters.py
diff --git a/backend/app/api/metrics.py b/backend/app/api/metrics.py
index ac3c2f9e..5b242d95 100644
--- a/backend/app/api/metrics.py
+++ b/backend/app/api/metrics.py
@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
from uuid import UUID
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import DateTime, case
from sqlalchemy import cast as sql_cast
from sqlalchemy import func
@@ -18,6 +18,7 @@ from app.core.time import utcnow
from app.db.session import get_session
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
+from app.models.boards import Board
from app.models.tasks import Task
from app.schemas.metrics import (
DashboardBucketKey,
@@ -38,6 +39,8 @@ router = APIRouter(prefix="/metrics", tags=["metrics"])
ERROR_EVENT_PATTERN = "%failed"
_RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession)
RANGE_QUERY = Query(default="24h")
+BOARD_ID_QUERY = Query(default=None)
+GROUP_ID_QUERY = Query(default=None)
SESSION_DEP = Depends(get_session)
ORG_MEMBER_DEP = Depends(require_org_member)
@@ -385,16 +388,54 @@ async def _tasks_in_progress(
return int(result)
+async def _resolve_dashboard_board_ids(
+ session: AsyncSession,
+ *,
+ ctx: OrganizationContext,
+ board_id: UUID | None,
+ group_id: UUID | None,
+) -> list[UUID]:
+ board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
+ if not board_ids:
+ return []
+ allowed = set(board_ids)
+
+ if board_id is not None and board_id not in allowed:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
+
+ if group_id is None:
+ return [board_id] if board_id is not None else board_ids
+
+ group_board_ids = list(
+ await session.exec(
+ select(Board.id)
+ .where(col(Board.organization_id) == ctx.member.organization_id)
+ .where(col(Board.board_group_id) == group_id)
+ .where(col(Board.id).in_(board_ids)),
+ ),
+ )
+ if board_id is not None:
+ return [board_id] if board_id in set(group_board_ids) else []
+ return group_board_ids
+
+
@router.get("/dashboard", response_model=DashboardMetrics)
async def dashboard_metrics(
range_key: DashboardRangeKey = RANGE_QUERY,
+ board_id: UUID | None = BOARD_ID_QUERY,
+ group_id: UUID | None = GROUP_ID_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DashboardMetrics:
"""Return dashboard KPIs and time-series data for accessible boards."""
primary = _resolve_range(range_key)
comparison = _comparison_range(primary)
- board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
+ board_ids = await _resolve_dashboard_board_ids(
+ session,
+ ctx=ctx,
+ board_id=board_id,
+ group_id=group_id,
+ )
throughput_primary = await _query_throughput(session, primary, board_ids)
throughput_comparison = await _query_throughput(session, comparison, board_ids)
diff --git a/backend/tests/test_error_handling.py b/backend/tests/test_error_handling.py
index d8f1f5ac..9c7dda08 100644
--- a/backend/tests/test_error_handling.py
+++ b/backend/tests/test_error_handling.py
@@ -14,6 +14,7 @@ from app.core.error_handling import (
_error_payload,
_get_request_id,
_http_exception_exception_handler,
+ _json_safe,
_request_validation_exception_handler,
_response_validation_exception_handler,
install_error_handling,
@@ -209,6 +210,20 @@ def test_error_payload_omits_request_id_when_none() -> None:
assert _error_payload(detail="x", request_id=None) == {"detail": "x"}
+def test_json_safe_handles_binary_inputs() -> None:
+ assert _json_safe(b"\xf0\x9f\x92\xa1") == "đź’ˇ"
+ assert _json_safe(bytearray(b"hello")) == "hello"
+ assert _json_safe(memoryview(b"world")) == "world"
+
+
+def test_json_safe_falls_back_to_string_for_unknown_objects() -> None:
+ class Weird:
+ def __str__(self) -> str:
+ return "weird-value"
+
+ assert _json_safe(Weird()) == "weird-value"
+
+
@pytest.mark.asyncio
async def test_request_validation_exception_wrapper_rejects_wrong_exception() -> None:
req = Request({"type": "http", "headers": [], "state": {}})
diff --git a/backend/tests/test_metrics_filters.py b/backend/tests/test_metrics_filters.py
new file mode 100644
index 00000000..d9a0f3c2
--- /dev/null
+++ b/backend/tests/test_metrics_filters.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from uuid import uuid4
+
+import pytest
+from fastapi import HTTPException
+
+from app.api import metrics as metrics_api
+
+
+class _FakeSession:
+ def __init__(self, exec_result: list[object]) -> None:
+ self._exec_result = exec_result
+
+ async def exec(self, _statement: object) -> list[object]:
+ return self._exec_result
+
+
+@pytest.mark.asyncio
+async def test_resolve_dashboard_board_ids_returns_requested_board(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ board_id = uuid4()
+
+ async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
+ return [board_id]
+
+ monkeypatch.setattr(
+ metrics_api,
+ "list_accessible_board_ids",
+ _accessible,
+ )
+ ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
+
+ resolved = await metrics_api._resolve_dashboard_board_ids(
+ _FakeSession([]),
+ ctx=ctx,
+ board_id=board_id,
+ group_id=None,
+ )
+
+ assert resolved == [board_id]
+
+
+@pytest.mark.asyncio
+async def test_resolve_dashboard_board_ids_rejects_inaccessible_board(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ accessible_board_id = uuid4()
+ requested_board_id = uuid4()
+
+ async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
+ return [accessible_board_id]
+
+ monkeypatch.setattr(
+ metrics_api,
+ "list_accessible_board_ids",
+ _accessible,
+ )
+ ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
+
+ with pytest.raises(HTTPException) as exc_info:
+ await metrics_api._resolve_dashboard_board_ids(
+ _FakeSession([]),
+ ctx=ctx,
+ board_id=requested_board_id,
+ group_id=None,
+ )
+
+ assert exc_info.value.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_resolve_dashboard_board_ids_filters_by_group(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ board_a = uuid4()
+ board_b = uuid4()
+ group_id = uuid4()
+
+ async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
+ return [board_a, board_b]
+
+ monkeypatch.setattr(
+ metrics_api,
+ "list_accessible_board_ids",
+ _accessible,
+ )
+ ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
+ session = _FakeSession([board_b])
+
+ resolved = await metrics_api._resolve_dashboard_board_ids(
+ session,
+ ctx=ctx,
+ board_id=None,
+ group_id=group_id,
+ )
+
+ assert resolved == [board_b]
+
+
+@pytest.mark.asyncio
+async def test_resolve_dashboard_board_ids_returns_empty_when_board_not_in_group(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ board_id = uuid4()
+ group_id = uuid4()
+
+ async def _accessible(*_args: object, **_kwargs: object) -> list[object]:
+ return [board_id]
+
+ monkeypatch.setattr(
+ metrics_api,
+ "list_accessible_board_ids",
+ _accessible,
+ )
+ ctx = SimpleNamespace(member=SimpleNamespace(organization_id=uuid4()))
+ session = _FakeSession([])
+
+ resolved = await metrics_api._resolve_dashboard_board_ids(
+ session,
+ ctx=ctx,
+ board_id=board_id,
+ group_id=group_id,
+ )
+
+ assert resolved == []
diff --git a/frontend/src/api/generated/model/approvalCreate.ts b/frontend/src/api/generated/model/approvalCreate.ts
index 6c4875c4..8d885162 100644
--- a/frontend/src/api/generated/model/approvalCreate.ts
+++ b/frontend/src/api/generated/model/approvalCreate.ts
@@ -16,6 +16,10 @@ export interface ApprovalCreate {
task_id?: string | null;
task_ids?: string[];
payload?: ApprovalCreatePayload;
+ /**
+ * @minimum 0
+ * @maximum 100
+ */
confidence: number;
rubric_scores?: ApprovalCreateRubricScores;
status?: ApprovalCreateStatus;
diff --git a/frontend/src/api/generated/model/approvalRead.ts b/frontend/src/api/generated/model/approvalRead.ts
index 86d3430a..48a9c348 100644
--- a/frontend/src/api/generated/model/approvalRead.ts
+++ b/frontend/src/api/generated/model/approvalRead.ts
@@ -16,11 +16,16 @@ export interface ApprovalRead {
task_id?: string | null;
task_ids?: string[];
payload?: ApprovalReadPayload;
+ /**
+ * @minimum 0
+ * @maximum 100
+ */
confidence: number;
rubric_scores?: ApprovalReadRubricScores;
status?: ApprovalReadStatus;
id: string;
board_id: string;
+ task_titles?: string[];
agent_id?: string | null;
created_at: string;
resolved_at?: string | null;
diff --git a/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetParams.ts b/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetParams.ts
index 83ffd4e8..bddc3079 100644
--- a/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetParams.ts
+++ b/frontend/src/api/generated/model/dashboardMetricsApiV1MetricsDashboardGetParams.ts
@@ -8,4 +8,6 @@ import type { DashboardMetricsApiV1MetricsDashboardGetRangeKey } from "./dashboa
export type DashboardMetricsApiV1MetricsDashboardGetParams = {
range_key?: DashboardMetricsApiV1MetricsDashboardGetRangeKey;
+ board_id?: string | null;
+ group_id?: string | null;
};
diff --git a/frontend/src/app/board-groups/[groupId]/page.tsx b/frontend/src/app/board-groups/[groupId]/page.tsx
index c81c924a..fb8db638 100644
--- a/frontend/src/app/board-groups/[groupId]/page.tsx
+++ b/frontend/src/app/board-groups/[groupId]/page.tsx
@@ -139,6 +139,7 @@ const SSE_RECONNECT_BACKOFF = {
jitter: 0.2,
maxMs: 5 * 60_000,
} as const;
+const HAS_ALL_MENTION_RE = /(^|\s)@all\b/i;
type HeartbeatUnit = "s" | "m" | "h" | "d";
@@ -231,6 +232,17 @@ export default function BoardGroupDetailPage() {
});
return ids;
}, [boards]);
+ const groupMentionSuggestions = useMemo(() => {
+ const options = new Set(["lead", "all"]);
+ boards.forEach((item) => {
+ (item.tasks ?? []).forEach((task) => {
+ if (task.assignee) {
+ options.add(task.assignee);
+ }
+ });
+ });
+ return [...options];
+ }, [boards]);
const membershipQuery = useGetMyMembershipApiV1OrganizationsMeMemberGet<
getMyMembershipApiV1OrganizationsMeMemberGetResponse,
@@ -599,7 +611,9 @@ export default function BoardGroupDetailPage() {
setIsChatSending(true);
setChatError(null);
try {
- const tags = ["chat", ...(chatBroadcast ? ["broadcast"] : [])];
+ const shouldBroadcast =
+ chatBroadcast || HAS_ALL_MENTION_RE.test(trimmed);
+ const tags = ["chat", ...(shouldBroadcast ? ["broadcast"] : [])];
const result =
await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost(
groupId,
@@ -641,7 +655,9 @@ export default function BoardGroupDetailPage() {
setIsNoteSending(true);
setNoteSendError(null);
try {
- const tags = ["note", ...(notesBroadcast ? ["broadcast"] : [])];
+ const shouldBroadcast =
+ notesBroadcast || HAS_ALL_MENTION_RE.test(trimmed);
+ const tags = ["note", ...(shouldBroadcast ? ["broadcast"] : [])];
const result =
await createBoardGroupMemoryApiV1BoardGroupsGroupIdMemoryPost(
groupId,
@@ -1156,6 +1172,7 @@ export default function BoardGroupDetailPage() {
isSending={isChatSending}
onSend={sendGroupChat}
disabled={!canWriteGroup}
+ mentionSuggestions={groupMentionSuggestions}
/>
@@ -1242,6 +1259,7 @@ export default function BoardGroupDetailPage() {
isSending={isNoteSending}
onSend={sendGroupNote}
disabled={!canWriteGroup}
+ mentionSuggestions={groupMentionSuggestions}
/>
diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx
index dd446153..6449ea63 100644
--- a/frontend/src/app/boards/[boardId]/page.tsx
+++ b/frontend/src/app/boards/[boardId]/page.tsx
@@ -92,7 +92,12 @@ import type {
TaskRead,
} from "@/api/generated/model";
import { createExponentialBackoff } from "@/lib/backoff";
-import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
+import {
+ apiDatetimeToMs,
+ localDateInputToUtcIso,
+ parseApiDatetime,
+ toLocalDateInput,
+} from "@/lib/datetime";
import { cn } from "@/lib/utils";
import { usePageActive } from "@/hooks/usePageActive";
@@ -738,8 +743,6 @@ export default function BoardDetailPage() {
const liveFeedHistoryLoadedRef = useRef(false);
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
const [commentsError, setCommentsError] = useState(null);
- const [newComment, setNewComment] = useState("");
- const taskCommentInputRef = useRef(null);
const [isPostingComment, setIsPostingComment] = useState(false);
const [postCommentError, setPostCommentError] = useState(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
@@ -1001,6 +1004,7 @@ export default function BoardDetailPage() {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState("medium");
+ const [createDueDate, setCreateDueDate] = useState("");
const [createTagIds, setCreateTagIds] = useState([]);
const [createError, setCreateError] = useState(null);
const [isCreating, setIsCreating] = useState(false);
@@ -1009,6 +1013,7 @@ export default function BoardDetailPage() {
const [editDescription, setEditDescription] = useState("");
const [editStatus, setEditStatus] = useState("inbox");
const [editPriority, setEditPriority] = useState("medium");
+ const [editDueDate, setEditDueDate] = useState("");
const [editAssigneeId, setEditAssigneeId] = useState("");
const [editTagIds, setEditTagIds] = useState([]);
const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState(
@@ -1484,6 +1489,7 @@ export default function BoardDetailPage() {
setEditDescription("");
setEditStatus("inbox");
setEditPriority("medium");
+ setEditDueDate("");
setEditAssigneeId("");
setEditTagIds([]);
setEditDependsOnTaskIds([]);
@@ -1494,6 +1500,7 @@ export default function BoardDetailPage() {
setEditDescription(selectedTask.description ?? "");
setEditStatus(selectedTask.status);
setEditPriority(selectedTask.priority);
+ setEditDueDate(toLocalDateInput(selectedTask.due_at));
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
setEditTagIds(selectedTask.tag_ids ?? []);
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
@@ -1802,6 +1809,7 @@ export default function BoardDetailPage() {
setTitle("");
setDescription("");
setPriority("medium");
+ setCreateDueDate("");
setCreateTagIds([]);
setCreateError(null);
};
@@ -1821,6 +1829,7 @@ export default function BoardDetailPage() {
description: description.trim() || null,
status: "inbox",
priority,
+ due_at: localDateInputToUtcIso(createDueDate),
tag_ids: createTagIds,
});
if (result.status !== 200) throw new Error("Unable to create task.");
@@ -1973,6 +1982,15 @@ export default function BoardDetailPage() {
() => agents.filter((agent) => !agent.is_board_lead),
[agents],
);
+ const boardChatMentionSuggestions = useMemo(() => {
+ const options = new Set(["lead"]);
+ agents.forEach((agent) => {
+ if (agent.name) {
+ options.add(agent.name);
+ }
+ });
+ return [...options];
+ }, [agents]);
const tagById = useMemo(() => {
const map = new Map();
@@ -2045,6 +2063,7 @@ export default function BoardDetailPage() {
const normalizedTitle = editTitle.trim();
const normalizedDescription = editDescription.trim();
const currentDescription = (selectedTask.description ?? "").trim();
+ const currentDueDate = toLocalDateInput(selectedTask.due_at);
const currentAssignee = selectedTask.assigned_agent_id ?? "";
const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|");
const nextTags = [...editTagIds].sort().join("|");
@@ -2057,12 +2076,14 @@ export default function BoardDetailPage() {
normalizedDescription !== currentDescription ||
editStatus !== selectedTask.status ||
editPriority !== selectedTask.priority ||
+ editDueDate !== currentDueDate ||
editAssigneeId !== currentAssignee ||
currentTags !== nextTags ||
currentDeps !== nextDeps
);
}, [
editAssigneeId,
+ editDueDate,
editTagIds,
editDependsOnTaskIds,
editDescription,
@@ -2205,7 +2226,6 @@ export default function BoardDetailPage() {
setSelectedTask(null);
setComments([]);
setCommentsError(null);
- setNewComment("");
setPostCommentError(null);
setIsEditDialogOpen(false);
};
@@ -2237,12 +2257,12 @@ export default function BoardDetailPage() {
setIsLiveFeedOpen(false);
};
- const handlePostComment = async () => {
- if (!selectedTask || !boardId || !isSignedIn) return;
- const trimmed = newComment.trim();
+ const handlePostComment = async (message: string): Promise => {
+ if (!selectedTask || !boardId || !isSignedIn) return false;
+ const trimmed = message.trim();
if (!trimmed) {
setPostCommentError("Write a message before sending.");
- return;
+ return false;
}
setIsPostingComment(true);
setPostCommentError(null);
@@ -2256,14 +2276,14 @@ export default function BoardDetailPage() {
if (result.status !== 200) throw new Error("Unable to send message.");
const created = result.data;
setComments((prev) => [created, ...prev]);
- setNewComment("");
+ return true;
} catch (err) {
const message = formatActionError(err, "Unable to send message.");
setPostCommentError(message);
pushToast(message);
+ return false;
} finally {
setIsPostingComment(false);
- taskCommentInputRef.current?.focus();
}
};
@@ -2285,6 +2305,8 @@ export default function BoardDetailPage() {
const currentTags = [...(selectedTask.tag_ids ?? [])].sort().join("|");
const nextTags = [...editTagIds].sort().join("|");
const tagsChanged = currentTags !== nextTags;
+ const currentDueDate = toLocalDateInput(selectedTask.due_at);
+ const dueDateChanged = editDueDate !== currentDueDate;
const updatePayload: Parameters<
typeof updateTaskApiV1BoardsBoardIdTasksTaskIdPatch
@@ -2302,6 +2324,9 @@ export default function BoardDetailPage() {
if (tagsChanged) {
updatePayload.tag_ids = editTagIds;
}
+ if (dueDateChanged) {
+ updatePayload.due_at = localDateInputToUtcIso(editDueDate);
+ }
const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
boardId,
@@ -2362,6 +2387,7 @@ export default function BoardDetailPage() {
setEditDescription(selectedTask.description ?? "");
setEditStatus(selectedTask.status);
setEditPriority(selectedTask.priority);
+ setEditDueDate(toLocalDateInput(selectedTask.due_at));
setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
setEditTagIds(selectedTask.tag_ids ?? []);
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
@@ -3520,27 +3546,16 @@ export default function BoardDetailPage() {
Comments
{isCommentsLoading ? (
Loading comments…
@@ -3638,6 +3641,7 @@ export default function BoardDetailPage() {
isSending={isChatSending}
onSend={handleSendChat}
disabled={!canWrite}
+ mentionSuggestions={boardChatMentionSuggestions}
placeholder={
canWrite
? "Message the board lead. Tag agents with @name."
@@ -3803,6 +3807,17 @@ export default function BoardDetailPage() {
+
+
+ setEditDueDate(event.target.value)}
+ disabled={!selectedTask || isSavingTask || !canWrite}
+ />
+
+
+
+ setCreateDueDate(event.target.value)}
+ disabled={!canWrite || isCreating}
+ />
+
diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx
index b46e8adc..7289b7a5 100644
--- a/frontend/src/app/dashboard/page.tsx
+++ b/frontend/src/app/dashboard/page.tsx
@@ -3,6 +3,7 @@
export const dynamic = "force-dynamic";
import { useMemo } from "react";
+import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
@@ -24,9 +25,19 @@ import { Activity, PenSquare, Timer, Users } from "lucide-react";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
-import DropdownSelect from "@/components/ui/dropdown-select";
+import DropdownSelect, {
+ type DropdownSelectOption,
+} from "@/components/ui/dropdown-select";
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
import { ApiError } from "@/api/mutator";
+import {
+ type listBoardGroupsApiV1BoardGroupsGetResponse,
+ useListBoardGroupsApiV1BoardGroupsGet,
+} from "@/api/generated/board-groups/board-groups";
+import {
+ type listBoardsApiV1BoardsGetResponse,
+ useListBoardsApiV1BoardsGet,
+} from "@/api/generated/boards/boards";
import {
type dashboardMetricsApiV1MetricsDashboardGetResponse,
useDashboardMetricsApiV1MetricsDashboardGet,
@@ -85,6 +96,7 @@ const DASHBOARD_RANGE_OPTIONS: Array<{ value: RangeKey; label: string }> = [
const DASHBOARD_RANGE_SET = new Set(
DASHBOARD_RANGE_OPTIONS.map((option) => option.value),
);
+const ALL_FILTER_VALUE = "all";
const DEFAULT_RANGE: RangeKey = "7d";
const formatPeriod = (value: string, bucket: BucketKey) => {
@@ -251,16 +263,111 @@ export default function DashboardPage() {
const router = useRouter();
const searchParams = useSearchParams();
const selectedRangeParam = searchParams.get("range");
+ const selectedGroupParam = searchParams.get("group");
+ const selectedBoardParam = searchParams.get("board");
const selectedRange: RangeKey =
selectedRangeParam &&
DASHBOARD_RANGE_SET.has(selectedRangeParam as RangeKey)
? (selectedRangeParam as RangeKey)
: DEFAULT_RANGE;
+ const selectedGroupId =
+ selectedGroupParam && selectedGroupParam !== ALL_FILTER_VALUE
+ ? selectedGroupParam
+ : null;
+ const selectedBoardId =
+ selectedBoardParam && selectedBoardParam !== ALL_FILTER_VALUE
+ ? selectedBoardParam
+ : null;
+
+ const boardsQuery = useListBoardsApiV1BoardsGet<
+ listBoardsApiV1BoardsGetResponse,
+ ApiError
+ >(
+ { limit: 200 },
+ {
+ query: {
+ enabled: Boolean(isSignedIn),
+ refetchInterval: 30_000,
+ refetchOnMount: "always",
+ },
+ },
+ );
+ const boardGroupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
+ listBoardGroupsApiV1BoardGroupsGetResponse,
+ ApiError
+ >(
+ { limit: 200 },
+ {
+ query: {
+ enabled: Boolean(isSignedIn),
+ refetchInterval: 30_000,
+ refetchOnMount: "always",
+ },
+ },
+ );
+
+ const boards = useMemo(
+ () =>
+ boardsQuery.data?.status === 200
+ ? [...(boardsQuery.data.data.items ?? [])].sort((a, b) =>
+ a.name.localeCompare(b.name),
+ )
+ : [],
+ [boardsQuery.data],
+ );
+ const boardGroups = useMemo(
+ () =>
+ boardGroupsQuery.data?.status === 200
+ ? [...(boardGroupsQuery.data.data.items ?? [])].sort((a, b) =>
+ a.name.localeCompare(b.name),
+ )
+ : [],
+ [boardGroupsQuery.data],
+ );
+
+ const filteredBoards = useMemo(
+ () =>
+ selectedGroupId
+ ? boards.filter((board) => board.board_group_id === selectedGroupId)
+ : boards,
+ [boards, selectedGroupId],
+ );
+ const selectedBoard = useMemo(
+ () => boards.find((board) => board.id === selectedBoardId) ?? null,
+ [boards, selectedBoardId],
+ );
+ const selectedGroup = useMemo(
+ () => boardGroups.find((group) => group.id === selectedGroupId) ?? null,
+ [boardGroups, selectedGroupId],
+ );
+
+ const boardGroupOptions = useMemo(
+ () => [
+ { value: ALL_FILTER_VALUE, label: "All groups" },
+ ...boardGroups.map((group) => ({ value: group.id, label: group.name })),
+ ],
+ [boardGroups],
+ );
+ const boardOptions = useMemo(
+ () => [
+ { value: ALL_FILTER_VALUE, label: "All boards" },
+ ...filteredBoards.map((board) => ({
+ value: board.id,
+ label: board.name,
+ })),
+ ],
+ [filteredBoards],
+ );
+
const metricsQuery = useDashboardMetricsApiV1MetricsDashboardGet<
dashboardMetricsApiV1MetricsDashboardGetResponse,
ApiError
>(
- { range_key: selectedRange },
+ {
+ range_key: selectedRange,
+ board_id: selectedBoardId ?? undefined,
+ group_id: selectedGroupId ?? undefined,
+ },
{
query: {
enabled: Boolean(isSignedIn),
@@ -356,6 +463,75 @@ export default function DashboardPage() {
triggerClassName="h-9 min-w-[150px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
contentClassName="rounded-lg border border-slate-200"
/>
+ {
+ const nextGroupId =
+ value === ALL_FILTER_VALUE ? null : value;
+ const params = new URLSearchParams(searchParams.toString());
+ if (nextGroupId) {
+ params.set("group", nextGroupId);
+ } else {
+ params.delete("group");
+ }
+ if (selectedBoardId) {
+ const selectedBoardRecord = boards.find(
+ (board) => board.id === selectedBoardId,
+ );
+ const boardVisibleInScope = nextGroupId
+ ? selectedBoardRecord?.board_group_id === nextGroupId
+ : true;
+ if (!boardVisibleInScope) {
+ params.delete("board");
+ }
+ }
+ router.replace(`${pathname}?${params.toString()}`);
+ }}
+ options={boardGroupOptions}
+ ariaLabel="Dashboard board group filter"
+ placeholder="All groups"
+ triggerClassName="h-9 min-w-[170px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
+ contentClassName="rounded-lg border border-slate-200"
+ searchEnabled={false}
+ disabled={boardGroupsQuery.isLoading}
+ />
+ {
+ const nextBoardId =
+ value === ALL_FILTER_VALUE ? null : value;
+ const params = new URLSearchParams(searchParams.toString());
+ if (nextBoardId) {
+ params.set("board", nextBoardId);
+ } else {
+ params.delete("board");
+ }
+ router.replace(`${pathname}?${params.toString()}`);
+ }}
+ options={boardOptions}
+ ariaLabel="Dashboard board filter"
+ placeholder="All boards"
+ triggerClassName="h-9 min-w-[170px] rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
+ contentClassName="rounded-lg border border-slate-200"
+ searchEnabled={false}
+ disabled={boardsQuery.isLoading || boardOptions.length <= 1}
+ />
+ {selectedGroup ? (
+
+ Open group
+
+ ) : null}
+ {selectedBoard ? (
+
+ Open board
+
+ ) : null}
diff --git a/frontend/src/components/BoardChatComposer.tsx b/frontend/src/components/BoardChatComposer.tsx
index 2d4bcd59..3111130b 100644
--- a/frontend/src/components/BoardChatComposer.tsx
+++ b/frontend/src/components/BoardChatComposer.tsx
@@ -1,27 +1,90 @@
"use client";
-import { memo, useCallback, useEffect, useRef, useState } from "react";
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
+const MENTION_MAX_OPTIONS = 8;
+const MENTION_PATTERN = /(?:^|\s)@([A-Za-z0-9_-]{0,31})$/;
+
+type MentionTarget = {
+ start: number;
+ end: number;
+ query: string;
+};
+
type BoardChatComposerProps = {
placeholder?: string;
isSending?: boolean;
disabled?: boolean;
+ mentionSuggestions?: string[];
onSend: (content: string) => Promise;
};
+const normalizeMentionHandle = (raw: string): string | null => {
+ const trimmed = raw.trim().replace(/^@+/, "");
+ if (!trimmed) return null;
+ const token = trimmed.split(/\s+/)[0]?.replace(/[^A-Za-z0-9_-]/g, "") ?? "";
+ if (!token) return null;
+ if (!/^[A-Za-z]/.test(token)) return null;
+ return token.slice(0, 32).toLowerCase();
+};
+
+const findMentionTarget = (
+ text: string,
+ caret: number,
+): MentionTarget | null => {
+ if (caret < 0 || caret > text.length) return null;
+ const prefix = text.slice(0, caret);
+ const match = prefix.match(MENTION_PATTERN);
+ if (!match) return null;
+ const query = (match[1] ?? "").toLowerCase();
+ const start = caret - query.length - 1;
+ return { start, end: caret, query };
+};
+
function BoardChatComposerImpl({
placeholder = "Message the board lead. Tag agents with @name.",
isSending = false,
disabled = false,
+ mentionSuggestions,
onSend,
}: BoardChatComposerProps) {
const [value, setValue] = useState("");
+ const [mentionTarget, setMentionTarget] = useState(
+ null,
+ );
+ const [activeMentionIndex, setActiveMentionIndex] = useState(0);
const textareaRef = useRef(null);
+ const closeMenuTimeoutRef = useRef(null);
const shouldFocusAfterSendRef = useRef(false);
+ const mentionOptions = useMemo(() => {
+ const handles = new Set(["lead"]);
+ (mentionSuggestions ?? []).forEach((candidate) => {
+ const handle = normalizeMentionHandle(candidate);
+ if (handle) {
+ handles.add(handle);
+ }
+ });
+ return [...handles];
+ }, [mentionSuggestions]);
+
+ const filteredMentionOptions = useMemo(() => {
+ if (!mentionTarget) return [];
+ const query = mentionTarget.query;
+ const startsWithMatches = mentionOptions.filter((option) =>
+ option.startsWith(query),
+ );
+ return startsWithMatches.slice(0, MENTION_MAX_OPTIONS);
+ }, [mentionOptions, mentionTarget]);
+
+ const activeIndex =
+ filteredMentionOptions.length > 0
+ ? Math.min(activeMentionIndex, filteredMentionOptions.length - 1)
+ : 0;
+
useEffect(() => {
if (isSending) return;
if (!shouldFocusAfterSendRef.current) return;
@@ -29,6 +92,43 @@ function BoardChatComposerImpl({
textareaRef.current?.focus();
}, [isSending]);
+ useEffect(() => {
+ return () => {
+ if (closeMenuTimeoutRef.current !== null) {
+ window.clearTimeout(closeMenuTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ const refreshMentionTarget = useCallback(
+ (nextValue: string, caret: number) => {
+ const nextTarget = findMentionTarget(nextValue, caret);
+ setMentionTarget(nextTarget);
+ },
+ [],
+ );
+
+ const applyMentionSelection = useCallback(
+ (handle: string) => {
+ const textarea = textareaRef.current;
+ if (!textarea || !mentionTarget) return;
+ const replacement = `@${handle} `;
+ const nextValue =
+ value.slice(0, mentionTarget.start) +
+ replacement +
+ value.slice(mentionTarget.end);
+ setValue(nextValue);
+ setMentionTarget(null);
+ setActiveMentionIndex(0);
+ window.requestAnimationFrame(() => {
+ const nextCaret = mentionTarget.start + replacement.length;
+ textarea.focus();
+ textarea.setSelectionRange(nextCaret, nextCaret);
+ });
+ },
+ [mentionTarget, value],
+ );
+
const send = useCallback(async () => {
if (isSending || disabled) return;
const trimmed = value.trim();
@@ -37,26 +137,120 @@ function BoardChatComposerImpl({
shouldFocusAfterSendRef.current = true;
if (ok) {
setValue("");
+ setMentionTarget(null);
+ setActiveMentionIndex(0);
}
}, [disabled, isSending, onSend, value]);
return (