fix(activity): use API route metadata for feed links

This commit is contained in:
Abhimanyu Saharan
2026-03-04 16:25:16 +05:30
parent 4378d354f4
commit 81b9a586ed

View File

@@ -89,9 +89,11 @@ type FeedItem = {
actor_role: string | null; actor_role: string | null;
board_id: string | null; board_id: string | null;
board_name: string | null; board_name: string | null;
board_href: string | null;
task_id: string | null; task_id: string | null;
task_title: string | null; task_title: string | null;
title: string; title: string;
context_href: string | null;
}; };
type TaskMeta = { type TaskMeta = {
@@ -99,6 +101,10 @@ type TaskMeta = {
boardId: string | null; boardId: string | null;
}; };
type ActivityRouteParams = Record<string, string>;
const ACTIVITY_FEED_PATH = "/activity";
const TASK_EVENT_TYPES = new Set<TaskEventType>([ const TASK_EVENT_TYPES = new Set<TaskEventType>([
"task.comment", "task.comment",
"task.created", "task.created",
@@ -120,6 +126,73 @@ const formatShortTimestamp = (value: string) => {
}); });
}; };
const normalizeRouteParams = (
params: ActivityEventRead["route_params"] | ActivityRouteParams | null | undefined,
): ActivityRouteParams => {
if (!params || typeof params !== "object") return {};
return Object.entries(params).reduce<ActivityRouteParams>((acc, [key, value]) => {
if (typeof value === "string" && value.length > 0) {
acc[key] = value;
}
return acc;
}, {});
};
const buildRouteHref = (
routeName: string | null | undefined,
routeParams: ActivityRouteParams,
fallback: {
eventId: string;
eventType: string;
createdAt: string;
taskId: string | null;
},
): string => {
if (routeName === "board.approvals") {
const boardId = routeParams.boardId;
if (boardId) {
return `/boards/${encodeURIComponent(boardId)}/approvals`;
}
}
if (routeName === "board") {
const boardId = routeParams.boardId;
if (boardId) {
const params = new URLSearchParams();
Object.entries(routeParams).forEach(([key, value]) => {
if (key !== "boardId") params.set(key, value);
});
const query = params.toString();
return query
? `/boards/${encodeURIComponent(boardId)}?${query}`
: `/boards/${encodeURIComponent(boardId)}`;
}
}
const params = new URLSearchParams(
Object.keys(routeParams).length > 0
? routeParams
: {
eventId: fallback.eventId,
eventType: fallback.eventType,
createdAt: fallback.createdAt,
},
);
if (fallback.taskId && !params.has("taskId")) {
params.set("taskId", fallback.taskId);
}
return `${ACTIVITY_FEED_PATH}?${params.toString()}`;
};
const buildBoardHref = (
routeParams: ActivityRouteParams,
boardId: string | null,
): string | null => {
const resolved = routeParams.boardId ?? boardId;
if (!resolved) return null;
return `/boards/${encodeURIComponent(resolved)}`;
};
const feedItemElementId = (id: string): string => const feedItemElementId = (id: string): string =>
`activity-item-${id.replace(/[^a-zA-Z0-9_-]/g, "-")}`; `activity-item-${id.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
@@ -219,11 +292,6 @@ const FeedCard = memo(function FeedCard({
}) { }) {
const message = (item.message ?? "").trim(); const message = (item.message ?? "").trim();
const authorAvatar = (item.actor_name[0] ?? "A").toUpperCase(); const authorAvatar = (item.actor_name[0] ?? "A").toUpperCase();
const taskHref =
item.board_id && item.task_id
? `/boards/${item.board_id}?taskId=${item.task_id}`
: null;
const boardHref = item.board_id ? `/boards/${item.board_id}` : null;
return ( return (
<div <div
@@ -241,9 +309,9 @@ const FeedCard = memo(function FeedCard({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="min-w-0"> <div className="min-w-0">
{taskHref ? ( {item.context_href ? (
<Link <Link
href={taskHref} href={item.context_href}
className="block text-sm font-semibold leading-snug text-slate-900 transition hover:text-slate-950 hover:underline" className="block text-sm font-semibold leading-snug text-slate-900 transition hover:text-slate-950 hover:underline"
title={item.title} title={item.title}
style={{ style={{
@@ -269,9 +337,9 @@ const FeedCard = memo(function FeedCard({
> >
{eventLabel(item.event_type)} {eventLabel(item.event_type)}
</span> </span>
{boardHref && item.board_name ? ( {item.board_href && item.board_name ? (
<Link <Link
href={boardHref} href={item.board_href}
className="font-semibold text-slate-700 hover:text-slate-900 hover:underline" className="font-semibold text-slate-700 hover:text-slate-900 hover:underline"
> >
{item.board_name} {item.board_name}
@@ -424,12 +492,30 @@ export default function ActivityPage() {
); );
const mapTaskActivity = useCallback( const mapTaskActivity = useCallback(
(event: ActivityEventRead): FeedItem | null => { (
event: ActivityEventRead,
fallbackBoardId: string | null = null,
): FeedItem | null => {
if (!isTaskEventType(event.event_type)) return null; if (!isTaskEventType(event.event_type)) return null;
const meta = event.task_id const meta = event.task_id
? taskMetaByIdRef.current.get(event.task_id) ? taskMetaByIdRef.current.get(event.task_id)
: null; : null;
const boardId = meta?.boardId ?? null; const routeName = event.route_name ?? null;
const routeParams = normalizeRouteParams(event.route_params);
const taskId = event.task_id ?? routeParams.taskId ?? null;
const boardId =
meta?.boardId ??
event.board_id ??
routeParams.boardId ??
fallbackBoardId ??
null;
const fallbackRouteParams: ActivityRouteParams = {};
if (boardId) fallbackRouteParams.boardId = boardId;
if (taskId) fallbackRouteParams.taskId = taskId;
const effectiveRouteParams =
Object.keys(routeParams).length > 0 ? routeParams : fallbackRouteParams;
const effectiveRouteName =
routeName ?? (boardId ? "board" : "activity");
const author = resolveAuthor(event.agent_id, currentUserDisplayName); const author = resolveAuthor(event.agent_id, currentUserDisplayName);
return { return {
id: `activity:${event.id}`, id: `activity:${event.id}`,
@@ -442,10 +528,17 @@ export default function ActivityPage() {
actor_role: author.role, actor_role: author.role,
board_id: boardId, board_id: boardId,
board_name: boardNameForId(boardId), board_name: boardNameForId(boardId),
task_id: event.task_id ?? null, board_href: buildBoardHref(effectiveRouteParams, boardId),
task_id: taskId,
task_title: meta?.title ?? null, task_title: meta?.title ?? null,
title: title:
meta?.title ?? (event.task_id ? "Unknown task" : "Task activity"), meta?.title ?? (taskId ? "Unknown task" : "Task activity"),
context_href: buildRouteHref(effectiveRouteName, effectiveRouteParams, {
eventId: event.id,
eventType: event.event_type,
createdAt: event.created_at,
taskId,
}),
}; };
}, },
[boardNameForId, currentUserDisplayName, resolveAuthor], [boardNameForId, currentUserDisplayName, resolveAuthor],
@@ -457,6 +550,11 @@ export default function ActivityPage() {
? taskMetaByIdRef.current.get(comment.task_id) ? taskMetaByIdRef.current.get(comment.task_id)
: null; : null;
const boardId = meta?.boardId ?? fallbackBoardId; const boardId = meta?.boardId ?? fallbackBoardId;
const taskId = comment.task_id ?? null;
const routeParams: ActivityRouteParams = {};
if (boardId) routeParams.boardId = boardId;
if (taskId) routeParams.taskId = taskId;
routeParams.commentId = comment.id;
const author = resolveAuthor(comment.agent_id, currentUserDisplayName); const author = resolveAuthor(comment.agent_id, currentUserDisplayName);
return { return {
id: `comment:${comment.id}`, id: `comment:${comment.id}`,
@@ -469,10 +567,17 @@ export default function ActivityPage() {
actor_role: author.role, actor_role: author.role,
board_id: boardId, board_id: boardId,
board_name: boardNameForId(boardId), board_name: boardNameForId(boardId),
task_id: comment.task_id ?? null, board_href: buildBoardHref(routeParams, boardId),
task_id: taskId,
task_title: meta?.title ?? null, task_title: meta?.title ?? null,
title: title:
meta?.title ?? (comment.task_id ? "Unknown task" : "Task activity"), meta?.title ?? (taskId ? "Unknown task" : "Task activity"),
context_href: buildRouteHref("board", routeParams, {
eventId: comment.id,
eventType: "task.comment",
createdAt: comment.created_at,
taskId,
}),
}; };
}, },
[boardNameForId, currentUserDisplayName, resolveAuthor], [boardNameForId, currentUserDisplayName, resolveAuthor],
@@ -525,6 +630,8 @@ export default function ActivityPage() {
const taskMeta = approval.task_id const taskMeta = approval.task_id
? taskMetaByIdRef.current.get(approval.task_id) ? taskMetaByIdRef.current.get(approval.task_id)
: null; : null;
const routeParams: ActivityRouteParams = { boardId };
const taskId = approval.task_id ?? null;
return { return {
id: `approval:${approval.id}:${kind}:${stamp}`, id: `approval:${approval.id}:${kind}:${stamp}`,
@@ -537,9 +644,16 @@ export default function ActivityPage() {
actor_role: author.role, actor_role: author.role,
board_id: boardId, board_id: boardId,
board_name: boardNameForId(boardId), board_name: boardNameForId(boardId),
task_id: approval.task_id ?? null, board_href: buildBoardHref(routeParams, boardId),
task_id: taskId,
task_title: taskMeta?.title ?? null, task_title: taskMeta?.title ?? null,
title: `Approval · ${action}`, title: `Approval · ${action}`,
context_href: buildRouteHref("board.approvals", routeParams, {
eventId: approval.id,
eventType: kind,
createdAt: stamp,
taskId,
}),
}; };
}, },
[boardNameForId, currentUserDisplayName, resolveAuthor], [boardNameForId, currentUserDisplayName, resolveAuthor],
@@ -553,6 +667,7 @@ export default function ActivityPage() {
currentUserDisplayName, currentUserDisplayName,
); );
const command = content.startsWith("/"); const command = content.startsWith("/");
const routeParams: ActivityRouteParams = { boardId, panel: "chat" };
return { return {
id: `chat:${memory.id}`, id: `chat:${memory.id}`,
created_at: memory.created_at, created_at: memory.created_at,
@@ -564,9 +679,16 @@ export default function ActivityPage() {
actor_role: null, actor_role: null,
board_id: boardId, board_id: boardId,
board_name: boardNameForId(boardId), board_name: boardNameForId(boardId),
board_href: buildBoardHref(routeParams, boardId),
task_id: null, task_id: null,
task_title: null, task_title: null,
title: command ? "Board command" : "Board chat", title: command ? "Board command" : "Board chat",
context_href: buildRouteHref("board", routeParams, {
eventId: memory.id,
eventType: command ? "board.command" : "board.chat",
createdAt: memory.created_at,
taskId: null,
}),
}; };
}, },
[boardNameForId, currentUserDisplayName], [boardNameForId, currentUserDisplayName],
@@ -618,6 +740,10 @@ export default function ActivityPage() {
: kind === "agent.offline" : kind === "agent.offline"
? `${agent.name} is offline.` ? `${agent.name} is offline.`
: `${agent.name} updated (${humanizeStatus(nextStatus)}).`; : `${agent.name} updated (${humanizeStatus(nextStatus)}).`;
const boardId = agent.board_id ?? null;
const routeParams: ActivityRouteParams = boardId
? { boardId }
: {};
return { return {
id: `agent:${agent.id}:${isSnapshot ? "snapshot" : kind}:${stamp}`, id: `agent:${agent.id}:${isSnapshot ? "snapshot" : kind}:${stamp}`,
@@ -628,11 +754,21 @@ export default function ActivityPage() {
agent_id: agent.id, agent_id: agent.id,
actor_name: agent.name, actor_name: agent.name,
actor_role: roleFromAgent(agent), actor_role: roleFromAgent(agent),
board_id: agent.board_id ?? null, board_id: boardId,
board_name: boardNameForId(agent.board_id), board_name: boardNameForId(boardId),
board_href: buildBoardHref(routeParams, boardId),
task_id: null, task_id: null,
task_title: null, task_title: null,
title: `Agent · ${agent.name}`, title: `Agent · ${agent.name}`,
context_href:
boardId === null
? null
: buildRouteHref("board", routeParams, {
eventId: agent.id,
eventType: kind,
createdAt: stamp,
taskId: null,
}),
}; };
}, },
[boardNameForId], [boardNameForId],
@@ -871,12 +1007,8 @@ export default function ActivityPage() {
updateTaskMeta(payload.task, boardId); updateTaskMeta(payload.task, boardId);
} }
if (payload.activity) { if (payload.activity) {
const mapped = mapTaskActivity(payload.activity); const mapped = mapTaskActivity(payload.activity, boardId);
if (mapped) { if (mapped) {
if (!mapped.board_id) {
mapped.board_id = boardId;
mapped.board_name = boardNameForId(boardId);
}
if (!mapped.task_title && payload.task?.title) { if (!mapped.task_title && payload.task?.title) {
mapped.task_title = payload.task.title; mapped.task_title = payload.task.title;
mapped.title = payload.task.title; mapped.title = payload.task.title;