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

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