feat: enhance task management with due date handling and mention support

This commit is contained in:
Abhimanyu Saharan
2026-02-12 21:46:22 +05:30
parent 8e5fcd9243
commit 6cb5702a2b
13 changed files with 843 additions and 203 deletions

View File

@@ -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)

View File

@@ -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": {}})

View File

@@ -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 == []

View File

@@ -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;

View File

@@ -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;

View File

@@ -8,4 +8,6 @@ import type { DashboardMetricsApiV1MetricsDashboardGetRangeKey } from "./dashboa
export type DashboardMetricsApiV1MetricsDashboardGetParams = {
range_key?: DashboardMetricsApiV1MetricsDashboardGetRangeKey;
board_id?: string | null;
group_id?: string | null;
};

View File

@@ -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<string>(["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}
/>
</div>
</div>
@@ -1242,6 +1259,7 @@ export default function BoardGroupDetailPage() {
isSending={isNoteSending}
onSend={sendGroupNote}
disabled={!canWriteGroup}
mentionSuggestions={groupMentionSuggestions}
/>
</div>
</div>

View File

@@ -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<string | null>(null);
const [newComment, setNewComment] = useState("");
const taskCommentInputRef = useRef<HTMLTextAreaElement | null>(null);
const [isPostingComment, setIsPostingComment] = useState(false);
const [postCommentError, setPostCommentError] = useState<string | null>(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<string[]>([]);
const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
@@ -1009,6 +1013,7 @@ export default function BoardDetailPage() {
const [editDescription, setEditDescription] = useState("");
const [editStatus, setEditStatus] = useState<TaskStatus>("inbox");
const [editPriority, setEditPriority] = useState("medium");
const [editDueDate, setEditDueDate] = useState("");
const [editAssigneeId, setEditAssigneeId] = useState("");
const [editTagIds, setEditTagIds] = useState<string[]>([]);
const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState<string[]>(
@@ -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<string>(["lead"]);
agents.forEach((agent) => {
if (agent.name) {
options.add(agent.name);
}
});
return [...options];
}, [agents]);
const tagById = useMemo(() => {
const map = new Map<string, TagRead>();
@@ -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<boolean> => {
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
</p>
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
<Textarea
ref={taskCommentInputRef}
value={newComment}
onChange={(event) => setNewComment(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
if (!canWrite) return;
event.preventDefault();
if (isPostingComment) return;
if (!newComment.trim()) return;
void handlePostComment();
}}
<BoardChatComposer
placeholder={
canWrite
? "Write a message for the assigned agent"
? "Write a message for the assigned agent. Tag @lead or @name."
: "Read-only access. Comments are disabled."
}
className="min-h-[80px] bg-white"
disabled={!canWrite || isPostingComment}
isSending={isPostingComment}
onSend={handlePostComment}
disabled={!canWrite}
mentionSuggestions={boardChatMentionSuggestions}
/>
{postCommentError ? (
<p className="text-xs text-rose-600">{postCommentError}</p>
@@ -3550,18 +3565,6 @@ export default function BoardDetailPage() {
Read-only access. You cannot post comments on this board.
</p>
) : null}
<div className="flex justify-end">
<Button
size="sm"
onClick={handlePostComment}
disabled={
!canWrite || isPostingComment || !newComment.trim()
}
title={canWrite ? "Send message" : "Read-only access"}
>
{isPostingComment ? "Sending…" : "Send message"}
</Button>
</div>
</div>
{isCommentsLoading ? (
<p className="text-sm text-slate-500">Loading comments</p>
@@ -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() {
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Due date
</label>
<Input
type="date"
value={editDueDate}
onChange={(event) => setEditDueDate(event.target.value)}
disabled={!selectedTask || isSavingTask || !canWrite}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
@@ -4094,6 +4109,17 @@ export default function BoardDetailPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">
Due date
</label>
<Input
type="date"
value={createDueDate}
onChange={(event) => setCreateDueDate(event.target.value)}
disabled={!canWrite || isCreating}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-sm font-medium text-strong">Tags</label>

View File

@@ -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<RangeKey>(
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<DropdownSelectOption[]>(
() => [
{ value: ALL_FILTER_VALUE, label: "All groups" },
...boardGroups.map((group) => ({ value: group.id, label: group.name })),
],
[boardGroups],
);
const boardOptions = useMemo<DropdownSelectOption[]>(
() => [
{ 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"
/>
<DropdownSelect
value={selectedGroupId ?? ALL_FILTER_VALUE}
onValueChange={(value) => {
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}
/>
<DropdownSelect
value={selectedBoardId ?? ALL_FILTER_VALUE}
onValueChange={(value) => {
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 ? (
<Link
href={`/board-groups/${selectedGroup.id}`}
className="inline-flex h-9 items-center rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50"
>
Open group
</Link>
) : null}
{selectedBoard ? (
<Link
href={`/boards/${selectedBoard.id}`}
className="inline-flex h-9 items-center rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50"
>
Open board
</Link>
) : null}
</div>
</div>
</div>

View File

@@ -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<boolean>;
};
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<MentionTarget | null>(
null,
);
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const closeMenuTimeoutRef = useRef<number | null>(null);
const shouldFocusAfterSendRef = useRef(false);
const mentionOptions = useMemo(() => {
const handles = new Set<string>(["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 (
<div className="mt-4 space-y-2">
<Textarea
ref={textareaRef}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
event.preventDefault();
void send();
}}
placeholder={placeholder}
className="min-h-[120px]"
disabled={isSending || disabled}
/>
<div className="relative">
<Textarea
ref={textareaRef}
value={value}
onChange={(event) => {
const nextValue = event.target.value;
setValue(nextValue);
refreshMentionTarget(
nextValue,
event.target.selectionStart ?? nextValue.length,
);
}}
onClick={(event) => {
refreshMentionTarget(
value,
event.currentTarget.selectionStart ?? value.length,
);
}}
onKeyUp={(event) => {
refreshMentionTarget(
value,
event.currentTarget.selectionStart ?? value.length,
);
}}
onBlur={() => {
if (closeMenuTimeoutRef.current !== null) {
window.clearTimeout(closeMenuTimeoutRef.current);
}
closeMenuTimeoutRef.current = window.setTimeout(() => {
setMentionTarget(null);
setActiveMentionIndex(0);
}, 120);
}}
onFocus={(event) => {
refreshMentionTarget(
value,
event.currentTarget.selectionStart ?? value.length,
);
}}
onKeyDown={(event) => {
if (filteredMentionOptions.length > 0 && mentionTarget) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveMentionIndex(
(prev) => (prev + 1) % filteredMentionOptions.length,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveMentionIndex(
(prev) =>
(prev - 1 + filteredMentionOptions.length) %
filteredMentionOptions.length,
);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
const selected = filteredMentionOptions[activeIndex];
if (selected) {
applyMentionSelection(selected);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
setMentionTarget(null);
setActiveMentionIndex(0);
return;
}
}
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
event.preventDefault();
void send();
}}
placeholder={placeholder}
className="min-h-[120px]"
disabled={isSending || disabled}
/>
{mentionTarget && filteredMentionOptions.length > 0 ? (
<div className="absolute bottom-full left-0 z-20 mb-2 w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
<div className="max-h-52 overflow-y-auto py-1">
{filteredMentionOptions.map((option, index) => (
<button
key={option}
type="button"
onMouseDown={(event) => {
event.preventDefault();
applyMentionSelection(option);
}}
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition ${
index === activeIndex
? "bg-slate-100 text-slate-900"
: "text-slate-700 hover:bg-slate-50"
}`}
>
<span className="font-mono">@{option}</span>
<span className="text-xs text-slate-400">mention</span>
</button>
))}
</div>
</div>
) : null}
</div>
<div className="flex justify-end">
<Button
onClick={() => void send()}

View File

@@ -67,7 +67,10 @@ const renderMentionsInText = (text: string, keyPrefix: string): ReactNode => {
return nodes;
};
const renderMentions = (content: ReactNode, keyPrefix = "mention"): ReactNode => {
const renderMentions = (
content: ReactNode,
keyPrefix = "mention",
): ReactNode => {
if (typeof content === "string") {
return renderMentionsInText(content, keyPrefix);
}

View File

@@ -10,6 +10,7 @@ interface TaskCardProps {
priority?: string;
assignee?: string;
due?: string;
isOverdue?: boolean;
approvalsPendingCount?: number;
tags?: Array<{ id: string; name: string; color: string }>;
isBlocked?: boolean;
@@ -27,6 +28,7 @@ export function TaskCard({
priority,
assignee,
due,
isOverdue = false,
approvalsPendingCount = 0,
tags = [],
isBlocked = false,
@@ -157,8 +159,18 @@ export function TaskCard({
<span>{assignee ?? "Unassigned"}</span>
</div>
{due ? (
<div className="flex items-center gap-2">
<CalendarClock className="h-4 w-4 text-slate-400" />
<div
className={cn(
"flex items-center gap-2",
isOverdue && "font-semibold text-rose-600",
)}
>
<CalendarClock
className={cn(
"h-4 w-4",
isOverdue ? "text-rose-500" : "text-slate-400",
)}
/>
<span>{due}</span>
</div>
) : null}

View File

@@ -82,14 +82,20 @@ const columns: Array<{
},
];
const formatDueDate = (value?: string | null) => {
if (!value) return undefined;
const date = parseApiDatetime(value);
if (!date) return undefined;
return date.toLocaleDateString(undefined, {
const resolveDueState = (
task: Task,
): { due: string | undefined; isOverdue: boolean } => {
const date = parseApiDatetime(task.due_at);
if (!date) return { due: undefined, isOverdue: false };
const dueLabel = date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
const isOverdue = task.status !== "done" && date.getTime() < Date.now();
return {
due: isOverdue ? `Overdue · ${dueLabel}` : dueLabel,
isOverdue,
};
};
type CardPosition = { left: number; top: number };
@@ -330,146 +336,156 @@ export const TaskBoard = memo(function TaskBoard({
ref={boardRef}
className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6"
>
{columns.map((column) => {
const columnTasks = grouped[column.status] ?? [];
const reviewCounts =
column.status === "review"
? columnTasks.reduce(
(acc, task) => {
if (task.is_blocked) {
acc.blocked += 1;
{columns.map((column) => {
const columnTasks = grouped[column.status] ?? [];
const reviewCounts =
column.status === "review"
? columnTasks.reduce(
(acc, task) => {
if (task.is_blocked) {
acc.blocked += 1;
return acc;
}
if ((task.approvals_pending_count ?? 0) > 0) {
acc.approval_needed += 1;
return acc;
}
acc.waiting_lead += 1;
return acc;
}
if ((task.approvals_pending_count ?? 0) > 0) {
acc.approval_needed += 1;
return acc;
}
acc.waiting_lead += 1;
return acc;
},
{
all: columnTasks.length,
approval_needed: 0,
waiting_lead: 0,
blocked: 0,
},
)
: null;
},
{
all: columnTasks.length,
approval_needed: 0,
waiting_lead: 0,
blocked: 0,
},
)
: null;
const filteredTasks =
column.status === "review" && reviewBucket !== "all"
? columnTasks.filter((task) => {
if (reviewBucket === "blocked") return Boolean(task.is_blocked);
if (reviewBucket === "approval_needed")
return (
(task.approvals_pending_count ?? 0) > 0 && !task.is_blocked
);
if (reviewBucket === "waiting_lead")
return (
!task.is_blocked &&
(task.approvals_pending_count ?? 0) === 0
);
return true;
})
: columnTasks;
const filteredTasks =
column.status === "review" && reviewBucket !== "all"
? columnTasks.filter((task) => {
if (reviewBucket === "blocked")
return Boolean(task.is_blocked);
if (reviewBucket === "approval_needed")
return (
(task.approvals_pending_count ?? 0) > 0 &&
!task.is_blocked
);
if (reviewBucket === "waiting_lead")
return (
!task.is_blocked &&
(task.approvals_pending_count ?? 0) === 0
);
return true;
})
: columnTasks;
return (
<div
key={column.title}
className={cn(
"kanban-column min-h-[calc(100vh-260px)]",
activeColumn === column.status &&
!readOnly &&
"ring-2 ring-slate-200",
)}
onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
onDragLeave={readOnly ? undefined : 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="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
<h3 className="text-sm font-semibold text-slate-900">
{column.title}
</h3>
</div>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
column.badge,
)}
>
{filteredTasks.length}
</span>
</div>
{column.status === "review" && reviewCounts ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{(
[
{ key: "all", label: "All", count: reviewCounts.all },
{
key: "approval_needed",
label: "Approval needed",
count: reviewCounts.approval_needed,
},
{
key: "waiting_lead",
label: "Lead review",
count: reviewCounts.waiting_lead,
},
{
key: "blocked",
label: "Blocked",
count: reviewCounts.blocked,
},
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => setReviewBucket(option.key)}
className={cn(
"rounded-full border px-2.5 py-1 transition",
reviewBucket === option.key
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50",
)}
aria-pressed={reviewBucket === option.key}
>
{option.label} · {option.count}
</button>
))}
</div>
) : null}
</div>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
<div className="space-y-3">
{filteredTasks.map((task) => (
<div key={task.id} ref={setCardRef(task.id)}>
<TaskCard
title={task.title}
status={task.status}
priority={task.priority}
assignee={task.assignee ?? undefined}
due={formatDueDate(task.due_at)}
approvalsPendingCount={task.approvals_pending_count}
tags={task.tags}
isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)}
draggable={!readOnly && !task.is_blocked}
isDragging={draggingId === task.id}
onDragStart={readOnly ? undefined : handleDragStart(task)}
onDragEnd={readOnly ? undefined : handleDragEnd}
/>
return (
<div
key={column.title}
className={cn(
"kanban-column min-h-[calc(100vh-260px)]",
activeColumn === column.status &&
!readOnly &&
"ring-2 ring-slate-200",
)}
onDrop={readOnly ? undefined : handleDrop(column.status)}
onDragOver={readOnly ? undefined : handleDragOver(column.status)}
onDragLeave={
readOnly ? undefined : 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="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", column.dot)} />
<h3 className="text-sm font-semibold text-slate-900">
{column.title}
</h3>
</div>
))}
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
column.badge,
)}
>
{filteredTasks.length}
</span>
</div>
{column.status === "review" && reviewCounts ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
{(
[
{ key: "all", label: "All", count: reviewCounts.all },
{
key: "approval_needed",
label: "Approval needed",
count: reviewCounts.approval_needed,
},
{
key: "waiting_lead",
label: "Lead review",
count: reviewCounts.waiting_lead,
},
{
key: "blocked",
label: "Blocked",
count: reviewCounts.blocked,
},
] as const
).map((option) => (
<button
key={option.key}
type="button"
onClick={() => setReviewBucket(option.key)}
className={cn(
"rounded-full border px-2.5 py-1 transition",
reviewBucket === option.key
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50",
)}
aria-pressed={reviewBucket === option.key}
>
{option.label} · {option.count}
</button>
))}
</div>
) : null}
</div>
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
<div className="space-y-3">
{filteredTasks.map((task) => {
const dueState = resolveDueState(task);
return (
<div key={task.id} ref={setCardRef(task.id)}>
<TaskCard
title={task.title}
status={task.status}
priority={task.priority}
assignee={task.assignee ?? undefined}
due={dueState.due}
isOverdue={dueState.isOverdue}
approvalsPendingCount={task.approvals_pending_count}
tags={task.tags}
isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)}
draggable={!readOnly && !task.is_blocked}
isDragging={draggingId === task.id}
onDragStart={
readOnly ? undefined : handleDragStart(task)
}
onDragEnd={readOnly ? undefined : handleDragEnd}
/>
</div>
);
})}
</div>
</div>
</div>
</div>
);
})}
);
})}
</div>
);
});