feat: update activity feed to include various event types and improve messaging
This commit is contained in:
@@ -30,8 +30,8 @@ from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus,
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.services.activity_log import record_activity
|
||||
from app.services.approval_task_links import (
|
||||
lock_tasks_for_approval,
|
||||
load_task_ids_by_approval,
|
||||
lock_tasks_for_approval,
|
||||
normalize_task_ids,
|
||||
pending_approval_conflicts_by_task,
|
||||
replace_approval_task_links,
|
||||
@@ -408,7 +408,9 @@ async def update_approval(
|
||||
if "status" in updates:
|
||||
target_status = updates["status"]
|
||||
if target_status == "pending" and prior_status != "pending":
|
||||
task_ids_by_approval = await load_task_ids_by_approval(session, approval_ids=[approval.id])
|
||||
task_ids_by_approval = await load_task_ids_by_approval(
|
||||
session, approval_ids=[approval.id]
|
||||
)
|
||||
approval_task_ids = task_ids_by_approval.get(approval.id)
|
||||
if not approval_task_ids and approval.task_id is not None:
|
||||
approval_task_ids = [approval.task_id]
|
||||
|
||||
@@ -35,6 +35,7 @@ from app.models.boards import Board
|
||||
from app.models.task_dependencies import TaskDependency
|
||||
from app.models.task_fingerprints import TaskFingerprint
|
||||
from app.models.tasks import Task
|
||||
from app.schemas.activity_events import ActivityEventRead
|
||||
from app.schemas.common import OkResponse
|
||||
from app.schemas.errors import BlockedTaskError
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
@@ -648,7 +649,10 @@ def _task_event_payload(
|
||||
deps_map: dict[UUID, list[UUID]],
|
||||
dep_status: dict[UUID, str],
|
||||
) -> dict[str, object]:
|
||||
payload: dict[str, object] = {"type": event.event_type}
|
||||
payload: dict[str, object] = {
|
||||
"type": event.event_type,
|
||||
"activity": ActivityEventRead.model_validate(event).model_dump(mode="json"),
|
||||
}
|
||||
if event.event_type == "task.comment":
|
||||
payload["comment"] = _serialize_comment(event)
|
||||
return payload
|
||||
|
||||
@@ -196,10 +196,10 @@ async def pending_approval_conflicts_by_task(
|
||||
legacy_statement = legacy_statement.where(col(Approval.id) != exclude_approval_id)
|
||||
legacy_rows = list(await session.exec(legacy_statement))
|
||||
|
||||
for task_id, approval_id, _created_at in legacy_rows:
|
||||
if task_id is None:
|
||||
for legacy_task_id, approval_id, _created_at in legacy_rows:
|
||||
if legacy_task_id is None:
|
||||
continue
|
||||
conflicts.setdefault(task_id, approval_id)
|
||||
conflicts.setdefault(legacy_task_id, approval_id)
|
||||
|
||||
return conflicts
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ async def test_agent_token_lookup_should_not_verify_more_than_once(
|
||||
async def exec(self, _stmt: object) -> list[object]:
|
||||
agents = []
|
||||
for i in range(50):
|
||||
agents.append(SimpleNamespace(agent_token_hash=f"pbkdf2_sha256$1$salt{i}$digest{i}"))
|
||||
agents.append(
|
||||
SimpleNamespace(agent_token_hash=f"pbkdf2_sha256$1$salt{i}$digest{i}")
|
||||
)
|
||||
return agents
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
@@ -5,7 +5,7 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.tasks import _coerce_task_event_rows
|
||||
from app.api.tasks import _coerce_task_event_rows, _task_event_payload
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.tasks import Task
|
||||
|
||||
@@ -51,3 +51,65 @@ def test_coerce_task_event_rows_accepts_row_like_values():
|
||||
def test_coerce_task_event_rows_rejects_invalid_values():
|
||||
with pytest.raises(TypeError, match="Expected \\(ActivityEvent, Task \\| None\\) rows"):
|
||||
_coerce_task_event_rows([("bad", "row")])
|
||||
|
||||
|
||||
def test_task_event_payload_includes_activity_for_comment_event() -> None:
|
||||
task = Task(board_id=uuid4(), title="Ship patch")
|
||||
event = ActivityEvent(
|
||||
event_type="task.comment",
|
||||
message="Looks good.",
|
||||
task_id=task.id,
|
||||
agent_id=uuid4(),
|
||||
)
|
||||
|
||||
payload = _task_event_payload(
|
||||
event,
|
||||
task,
|
||||
deps_map={},
|
||||
dep_status={},
|
||||
)
|
||||
|
||||
assert payload["type"] == "task.comment"
|
||||
assert payload["activity"] == {
|
||||
"id": str(event.id),
|
||||
"event_type": "task.comment",
|
||||
"message": "Looks good.",
|
||||
"agent_id": str(event.agent_id),
|
||||
"task_id": str(task.id),
|
||||
"created_at": event.created_at.isoformat(),
|
||||
}
|
||||
comment = payload["comment"]
|
||||
assert isinstance(comment, dict)
|
||||
assert comment["id"] == str(event.id)
|
||||
assert comment["task_id"] == str(task.id)
|
||||
assert comment["message"] == "Looks good."
|
||||
|
||||
|
||||
def test_task_event_payload_includes_activity_for_non_comment_event() -> None:
|
||||
task = Task(board_id=uuid4(), title="Wire stream events", status="in_progress")
|
||||
event = ActivityEvent(
|
||||
event_type="task.updated",
|
||||
message="Task updated: Wire stream events.",
|
||||
task_id=task.id,
|
||||
)
|
||||
|
||||
payload = _task_event_payload(
|
||||
event,
|
||||
task,
|
||||
deps_map={},
|
||||
dep_status={},
|
||||
)
|
||||
|
||||
assert payload["type"] == "task.updated"
|
||||
assert payload["activity"] == {
|
||||
"id": str(event.id),
|
||||
"event_type": "task.updated",
|
||||
"message": "Task updated: Wire stream events.",
|
||||
"agent_id": None,
|
||||
"task_id": str(task.id),
|
||||
"created_at": event.created_at.isoformat(),
|
||||
}
|
||||
task_payload = payload["task"]
|
||||
assert isinstance(task_payload, dict)
|
||||
assert task_payload["id"] == str(task.id)
|
||||
assert task_payload["is_blocked"] is False
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,10 +53,7 @@ import {
|
||||
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
|
||||
updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
|
||||
} from "@/api/generated/approvals/approvals";
|
||||
import {
|
||||
listTaskCommentFeedApiV1ActivityTaskCommentsGet,
|
||||
streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet,
|
||||
} from "@/api/generated/activity/activity";
|
||||
import { listActivityApiV1ActivityGet } from "@/api/generated/activity/activity";
|
||||
import {
|
||||
getBoardGroupSnapshotApiV1BoardsBoardIdGroupSnapshotGet,
|
||||
getBoardSnapshotApiV1BoardsBoardIdSnapshotGet,
|
||||
@@ -83,6 +80,7 @@ import type {
|
||||
BoardGroupSnapshot,
|
||||
BoardMemoryRead,
|
||||
BoardRead,
|
||||
ActivityEventRead,
|
||||
OrganizationMemberRead,
|
||||
TaskCardRead,
|
||||
TaskCommentRead,
|
||||
@@ -115,6 +113,295 @@ type Approval = ApprovalRead & { status: string };
|
||||
|
||||
type BoardChatMessage = BoardMemoryRead;
|
||||
|
||||
type LiveFeedEventType =
|
||||
| "task.comment"
|
||||
| "task.created"
|
||||
| "task.updated"
|
||||
| "task.status_changed"
|
||||
| "board.chat"
|
||||
| "board.command"
|
||||
| "agent.created"
|
||||
| "agent.online"
|
||||
| "agent.offline"
|
||||
| "agent.updated"
|
||||
| "approval.created"
|
||||
| "approval.updated"
|
||||
| "approval.approved"
|
||||
| "approval.rejected";
|
||||
|
||||
type LiveFeedItem = {
|
||||
id: string;
|
||||
created_at: string;
|
||||
message: string | null;
|
||||
agent_id: string | null;
|
||||
actor_name?: string | null;
|
||||
task_id: string | null;
|
||||
title?: string | null;
|
||||
event_type: LiveFeedEventType;
|
||||
};
|
||||
|
||||
const LIVE_FEED_EVENT_TYPES = new Set<LiveFeedEventType>([
|
||||
"task.comment",
|
||||
"task.created",
|
||||
"task.updated",
|
||||
"task.status_changed",
|
||||
"board.chat",
|
||||
"board.command",
|
||||
"agent.created",
|
||||
"agent.online",
|
||||
"agent.offline",
|
||||
"agent.updated",
|
||||
"approval.created",
|
||||
"approval.updated",
|
||||
"approval.approved",
|
||||
"approval.rejected",
|
||||
]);
|
||||
|
||||
const isLiveFeedEventType = (value: string): value is LiveFeedEventType =>
|
||||
LIVE_FEED_EVENT_TYPES.has(value as LiveFeedEventType);
|
||||
|
||||
const toLiveFeedFromActivity = (
|
||||
event: ActivityEventRead,
|
||||
): LiveFeedItem | null => {
|
||||
if (!isLiveFeedEventType(event.event_type)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: event.id,
|
||||
created_at: event.created_at,
|
||||
message: event.message ?? null,
|
||||
agent_id: event.agent_id ?? null,
|
||||
task_id: event.task_id ?? null,
|
||||
title: null,
|
||||
event_type: event.event_type,
|
||||
};
|
||||
};
|
||||
|
||||
const toLiveFeedFromComment = (comment: TaskCommentRead): LiveFeedItem => ({
|
||||
id: comment.id,
|
||||
created_at: comment.created_at,
|
||||
message: comment.message ?? null,
|
||||
agent_id: comment.agent_id ?? null,
|
||||
actor_name: null,
|
||||
task_id: comment.task_id ?? null,
|
||||
title: null,
|
||||
event_type: "task.comment",
|
||||
});
|
||||
|
||||
const toLiveFeedFromBoardChat = (memory: BoardChatMessage): LiveFeedItem => {
|
||||
const content = (memory.content ?? "").trim();
|
||||
const actorName = (memory.source ?? "User").trim() || "User";
|
||||
const isCommand = content.startsWith("/");
|
||||
return {
|
||||
id: `chat:${memory.id}`,
|
||||
created_at: memory.created_at,
|
||||
message: content || null,
|
||||
agent_id: null,
|
||||
actor_name: actorName,
|
||||
task_id: null,
|
||||
title: isCommand ? "Board command" : "Board chat",
|
||||
event_type: isCommand ? "board.command" : "board.chat",
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAgentStatus = (value?: string | null): string => {
|
||||
const status = (value ?? "").trim().toLowerCase();
|
||||
return status || "offline";
|
||||
};
|
||||
|
||||
const humanizeAgentStatus = (value: string): string =>
|
||||
value.replace(/_/g, " ").trim() || "offline";
|
||||
|
||||
const toLiveFeedFromAgentSnapshot = (agent: Agent): LiveFeedItem => {
|
||||
const status = normalizeAgentStatus(agent.status);
|
||||
const stamp = agent.last_seen_at ?? agent.updated_at ?? agent.created_at;
|
||||
const eventType: LiveFeedEventType =
|
||||
status === "online"
|
||||
? "agent.online"
|
||||
: status === "offline"
|
||||
? "agent.offline"
|
||||
: "agent.updated";
|
||||
return {
|
||||
id: `agent:${agent.id}:snapshot:${status}:${stamp}`,
|
||||
created_at: stamp,
|
||||
message: `${agent.name} is ${humanizeAgentStatus(status)}.`,
|
||||
agent_id: agent.id,
|
||||
actor_name: null,
|
||||
task_id: null,
|
||||
title: `Agent · ${agent.name}`,
|
||||
event_type: eventType,
|
||||
};
|
||||
};
|
||||
|
||||
const toLiveFeedFromAgentUpdate = (
|
||||
agent: Agent,
|
||||
previous: Agent | null,
|
||||
): LiveFeedItem | null => {
|
||||
const nextStatus = normalizeAgentStatus(agent.status);
|
||||
const previousStatus = previous
|
||||
? normalizeAgentStatus(previous.status)
|
||||
: null;
|
||||
const statusChanged =
|
||||
previousStatus !== null && nextStatus !== previousStatus;
|
||||
const isNew = previous === null;
|
||||
const profileChanged =
|
||||
Boolean(previous) &&
|
||||
(previous?.name !== agent.name ||
|
||||
previous?.is_board_lead !== agent.is_board_lead ||
|
||||
JSON.stringify(previous?.identity_profile ?? {}) !==
|
||||
JSON.stringify(agent.identity_profile ?? {}));
|
||||
|
||||
let eventType: LiveFeedEventType;
|
||||
if (isNew) {
|
||||
eventType = "agent.created";
|
||||
} else if (statusChanged && nextStatus === "online") {
|
||||
eventType = "agent.online";
|
||||
} else if (statusChanged && nextStatus === "offline") {
|
||||
eventType = "agent.offline";
|
||||
} else if (statusChanged || profileChanged) {
|
||||
eventType = "agent.updated";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stamp = agent.last_seen_at ?? agent.updated_at ?? agent.created_at;
|
||||
const message =
|
||||
eventType === "agent.created"
|
||||
? `${agent.name} joined this board.`
|
||||
: eventType === "agent.online"
|
||||
? `${agent.name} is online.`
|
||||
: eventType === "agent.offline"
|
||||
? `${agent.name} is offline.`
|
||||
: `${agent.name} updated (${humanizeAgentStatus(nextStatus)}).`;
|
||||
return {
|
||||
id: `agent:${agent.id}:${eventType}:${stamp}`,
|
||||
created_at: stamp,
|
||||
message,
|
||||
agent_id: agent.id,
|
||||
actor_name: null,
|
||||
task_id: null,
|
||||
title: `Agent · ${agent.name}`,
|
||||
event_type: eventType,
|
||||
};
|
||||
};
|
||||
|
||||
const humanizeLiveFeedApprovalAction = (value: string): string => {
|
||||
const cleaned = value.replace(/[._-]+/g, " ").trim();
|
||||
if (!cleaned) return "Approval";
|
||||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
};
|
||||
|
||||
const toLiveFeedFromApproval = (
|
||||
approval: ApprovalRead,
|
||||
previous: ApprovalRead | null = null,
|
||||
): LiveFeedItem => {
|
||||
const nextStatus = approval.status ?? "pending";
|
||||
const previousStatus = previous?.status ?? null;
|
||||
const eventType: LiveFeedEventType =
|
||||
previousStatus === null
|
||||
? nextStatus === "approved"
|
||||
? "approval.approved"
|
||||
: nextStatus === "rejected"
|
||||
? "approval.rejected"
|
||||
: "approval.created"
|
||||
: nextStatus !== previousStatus
|
||||
? nextStatus === "approved"
|
||||
? "approval.approved"
|
||||
: nextStatus === "rejected"
|
||||
? "approval.rejected"
|
||||
: "approval.updated"
|
||||
: "approval.updated";
|
||||
const stamp =
|
||||
eventType === "approval.created"
|
||||
? approval.created_at
|
||||
: (approval.resolved_at ?? approval.created_at);
|
||||
const action = humanizeLiveFeedApprovalAction(approval.action_type);
|
||||
const statusText =
|
||||
nextStatus === "approved"
|
||||
? "approved"
|
||||
: nextStatus === "rejected"
|
||||
? "rejected"
|
||||
: "pending";
|
||||
const message =
|
||||
eventType === "approval.created"
|
||||
? `${action} requested (${approval.confidence}% confidence).`
|
||||
: eventType === "approval.approved"
|
||||
? `${action} approved (${approval.confidence}% confidence).`
|
||||
: eventType === "approval.rejected"
|
||||
? `${action} rejected (${approval.confidence}% confidence).`
|
||||
: `${action} updated (${statusText}, ${approval.confidence}% confidence).`;
|
||||
return {
|
||||
id: `approval:${approval.id}:${eventType}:${stamp}`,
|
||||
created_at: stamp,
|
||||
message,
|
||||
agent_id: approval.agent_id ?? null,
|
||||
actor_name: null,
|
||||
task_id: approval.task_id ?? null,
|
||||
title: `Approval · ${action}`,
|
||||
event_type: eventType,
|
||||
};
|
||||
};
|
||||
|
||||
const liveFeedEventLabel = (eventType: LiveFeedEventType): string => {
|
||||
if (eventType === "task.comment") return "Comment";
|
||||
if (eventType === "task.created") return "Created";
|
||||
if (eventType === "task.status_changed") return "Status";
|
||||
if (eventType === "board.chat") return "Chat";
|
||||
if (eventType === "board.command") return "Command";
|
||||
if (eventType === "agent.created") return "Agent";
|
||||
if (eventType === "agent.online") return "Online";
|
||||
if (eventType === "agent.offline") return "Offline";
|
||||
if (eventType === "agent.updated") return "Agent update";
|
||||
if (eventType === "approval.created") return "Approval";
|
||||
if (eventType === "approval.updated") return "Approval update";
|
||||
if (eventType === "approval.approved") return "Approved";
|
||||
if (eventType === "approval.rejected") return "Rejected";
|
||||
return "Updated";
|
||||
};
|
||||
|
||||
const liveFeedEventPillClass = (eventType: LiveFeedEventType): string => {
|
||||
if (eventType === "task.comment") {
|
||||
return "border-blue-200 bg-blue-50 text-blue-700";
|
||||
}
|
||||
if (eventType === "task.created") {
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
if (eventType === "task.status_changed") {
|
||||
return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
}
|
||||
if (eventType === "board.chat") {
|
||||
return "border-teal-200 bg-teal-50 text-teal-700";
|
||||
}
|
||||
if (eventType === "board.command") {
|
||||
return "border-fuchsia-200 bg-fuchsia-50 text-fuchsia-700";
|
||||
}
|
||||
if (eventType === "agent.created") {
|
||||
return "border-violet-200 bg-violet-50 text-violet-700";
|
||||
}
|
||||
if (eventType === "agent.online") {
|
||||
return "border-lime-200 bg-lime-50 text-lime-700";
|
||||
}
|
||||
if (eventType === "agent.offline") {
|
||||
return "border-slate-300 bg-slate-100 text-slate-700";
|
||||
}
|
||||
if (eventType === "agent.updated") {
|
||||
return "border-indigo-200 bg-indigo-50 text-indigo-700";
|
||||
}
|
||||
if (eventType === "approval.created") {
|
||||
return "border-cyan-200 bg-cyan-50 text-cyan-700";
|
||||
}
|
||||
if (eventType === "approval.updated") {
|
||||
return "border-sky-200 bg-sky-50 text-sky-700";
|
||||
}
|
||||
if (eventType === "approval.approved") {
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
if (eventType === "approval.rejected") {
|
||||
return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
}
|
||||
return "border-slate-200 bg-slate-100 text-slate-700";
|
||||
};
|
||||
|
||||
const normalizeTask = (task: TaskCardRead): Task => ({
|
||||
...task,
|
||||
status: task.status ?? "inbox",
|
||||
@@ -271,7 +558,7 @@ const ChatMessageCard = memo(function ChatMessageCard({
|
||||
ChatMessageCard.displayName = "ChatMessageCard";
|
||||
|
||||
const LiveFeedCard = memo(function LiveFeedCard({
|
||||
comment,
|
||||
item,
|
||||
taskTitle,
|
||||
authorName,
|
||||
authorRole,
|
||||
@@ -279,7 +566,7 @@ const LiveFeedCard = memo(function LiveFeedCard({
|
||||
onViewTask,
|
||||
isNew,
|
||||
}: {
|
||||
comment: TaskComment;
|
||||
item: LiveFeedItem;
|
||||
taskTitle: string;
|
||||
authorName: string;
|
||||
authorRole?: string | null;
|
||||
@@ -287,7 +574,9 @@ const LiveFeedCard = memo(function LiveFeedCard({
|
||||
onViewTask?: () => void;
|
||||
isNew?: boolean;
|
||||
}) {
|
||||
const message = (comment.message ?? "").trim();
|
||||
const message = (item.message ?? "").trim();
|
||||
const eventLabel = liveFeedEventLabel(item.event_type);
|
||||
const eventPillClass = liveFeedEventPillClass(item.event_type);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -323,19 +612,16 @@ const LiveFeedCard = memo(function LiveFeedCard({
|
||||
>
|
||||
{taskTitle}
|
||||
</button>
|
||||
{onViewTask ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewTask}
|
||||
className="inline-flex flex-shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-slate-600 transition hover:bg-slate-50 hover:text-slate-900"
|
||||
aria-label="View task"
|
||||
>
|
||||
View task
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide",
|
||||
eventPillClass,
|
||||
)}
|
||||
>
|
||||
{eventLabel}
|
||||
</span>
|
||||
<span className="font-medium text-slate-700">{authorName}</span>
|
||||
{authorRole ? (
|
||||
<>
|
||||
@@ -345,7 +631,7 @@ const LiveFeedCard = memo(function LiveFeedCard({
|
||||
) : null}
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="text-slate-400">
|
||||
{formatShortTimestamp(comment.created_at)}
|
||||
{formatShortTimestamp(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,8 +699,8 @@ export default function BoardDetailPage() {
|
||||
const selectedTaskIdRef = useRef<string | null>(null);
|
||||
const openedTaskIdFromUrlRef = useRef<string | null>(null);
|
||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
|
||||
const liveFeedRef = useRef<TaskComment[]>([]);
|
||||
const [liveFeed, setLiveFeed] = useState<LiveFeedItem[]>([]);
|
||||
const liveFeedRef = useRef<LiveFeedItem[]>([]);
|
||||
const liveFeedFlashTimersRef = useRef<Record<string, number>>({});
|
||||
const [liveFeedFlashIds, setLiveFeedFlashIds] = useState<
|
||||
Record<string, boolean>
|
||||
@@ -467,15 +753,15 @@ export default function BoardDetailPage() {
|
||||
const isLiveFeedOpenRef = useRef(false);
|
||||
const toastIdRef = useRef(0);
|
||||
const toastTimersRef = useRef<Record<number, number>>({});
|
||||
const pushLiveFeed = useCallback((comment: TaskComment) => {
|
||||
const pushLiveFeed = useCallback((item: LiveFeedItem) => {
|
||||
const alreadySeen = liveFeedRef.current.some(
|
||||
(item) => item.id === comment.id,
|
||||
(existing) => existing.id === item.id,
|
||||
);
|
||||
setLiveFeed((prev) => {
|
||||
if (prev.some((item) => item.id === comment.id)) {
|
||||
if (prev.some((existing) => existing.id === item.id)) {
|
||||
return prev;
|
||||
}
|
||||
const next = [comment, ...prev];
|
||||
const next = [item, ...prev];
|
||||
return next.slice(0, 50);
|
||||
});
|
||||
|
||||
@@ -483,20 +769,20 @@ export default function BoardDetailPage() {
|
||||
if (!isLiveFeedOpenRef.current) return;
|
||||
|
||||
setLiveFeedFlashIds((prev) =>
|
||||
prev[comment.id] ? prev : { ...prev, [comment.id]: true },
|
||||
prev[item.id] ? prev : { ...prev, [item.id]: true },
|
||||
);
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
const existingTimer = liveFeedFlashTimersRef.current[comment.id];
|
||||
const existingTimer = liveFeedFlashTimersRef.current[item.id];
|
||||
if (existingTimer !== undefined) {
|
||||
window.clearTimeout(existingTimer);
|
||||
}
|
||||
liveFeedFlashTimersRef.current[comment.id] = window.setTimeout(() => {
|
||||
delete liveFeedFlashTimersRef.current[comment.id];
|
||||
liveFeedFlashTimersRef.current[item.id] = window.setTimeout(() => {
|
||||
delete liveFeedFlashTimersRef.current[item.id];
|
||||
setLiveFeedFlashIds((prev) => {
|
||||
if (!prev[comment.id]) return prev;
|
||||
if (!prev[item.id]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[comment.id];
|
||||
delete next[item.id];
|
||||
return next;
|
||||
});
|
||||
}, 2200);
|
||||
@@ -566,6 +852,7 @@ export default function BoardDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!isLiveFeedOpen) return;
|
||||
if (!isSignedIn || !boardId) return;
|
||||
if (isLoading) return;
|
||||
if (liveFeedHistoryLoadedRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
@@ -574,28 +861,83 @@ export default function BoardDetailPage() {
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const result = await listTaskCommentFeedApiV1ActivityTaskCommentsGet({
|
||||
board_id: boardId,
|
||||
limit: 200,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (result.status !== 200) {
|
||||
throw new Error("Unable to load live feed.");
|
||||
const sourceTasks =
|
||||
tasksRef.current.length > 0 ? tasksRef.current : tasks;
|
||||
const sourceApprovals =
|
||||
approvalsRef.current.length > 0 ? approvalsRef.current : approvals;
|
||||
const sourceAgents =
|
||||
agentsRef.current.length > 0 ? agentsRef.current : agents;
|
||||
const sourceChatMessages =
|
||||
chatMessagesRef.current.length > 0
|
||||
? chatMessagesRef.current
|
||||
: chatMessages;
|
||||
const boardTaskIds = new Set(sourceTasks.map((task) => task.id));
|
||||
const collected: LiveFeedItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
const limit = 200;
|
||||
const recentChatMessages = [...sourceChatMessages]
|
||||
.sort((a, b) => {
|
||||
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
||||
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.slice(0, 50);
|
||||
for (const memory of recentChatMessages) {
|
||||
const chatItem = toLiveFeedFromBoardChat(memory);
|
||||
if (seen.has(chatItem.id)) continue;
|
||||
seen.add(chatItem.id);
|
||||
collected.push(chatItem);
|
||||
if (collected.length >= 200) break;
|
||||
}
|
||||
for (const agent of sourceAgents) {
|
||||
if (collected.length >= 200) break;
|
||||
const agentItem = toLiveFeedFromAgentSnapshot(agent);
|
||||
if (seen.has(agentItem.id)) continue;
|
||||
seen.add(agentItem.id);
|
||||
collected.push(agentItem);
|
||||
if (collected.length >= 200) break;
|
||||
}
|
||||
for (const approval of sourceApprovals) {
|
||||
if (collected.length >= 200) break;
|
||||
const approvalItem = toLiveFeedFromApproval(approval);
|
||||
if (seen.has(approvalItem.id)) continue;
|
||||
seen.add(approvalItem.id);
|
||||
collected.push(approvalItem);
|
||||
if (collected.length >= 200) break;
|
||||
}
|
||||
|
||||
for (
|
||||
let offset = 0;
|
||||
collected.length < 200 && offset < 1000;
|
||||
offset += limit
|
||||
) {
|
||||
const result = await listActivityApiV1ActivityGet({
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (result.status !== 200) {
|
||||
throw new Error("Unable to load live feed.");
|
||||
}
|
||||
const items = result.data.items ?? [];
|
||||
for (const event of items) {
|
||||
const mapped = toLiveFeedFromActivity(event);
|
||||
if (!mapped?.task_id) continue;
|
||||
if (!boardTaskIds.has(mapped.task_id)) continue;
|
||||
if (seen.has(mapped.id)) continue;
|
||||
seen.add(mapped.id);
|
||||
collected.push(mapped);
|
||||
if (collected.length >= 200) break;
|
||||
}
|
||||
if (collected.length >= 200 || items.length < limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const items = result.data.items ?? [];
|
||||
liveFeedHistoryLoadedRef.current = true;
|
||||
|
||||
const mapped: TaskComment[] = items.map((item) => ({
|
||||
id: item.id,
|
||||
message: item.message ?? null,
|
||||
agent_id: item.agent_id ?? null,
|
||||
task_id: item.task_id ?? null,
|
||||
created_at: item.created_at,
|
||||
}));
|
||||
|
||||
setLiveFeed((prev) => {
|
||||
const map = new Map<string, TaskComment>();
|
||||
[...prev, ...mapped].forEach((item) => map.set(item.id, item));
|
||||
const map = new Map<string, LiveFeedItem>();
|
||||
[...prev, ...collected].forEach((item) => map.set(item.id, item));
|
||||
const merged = [...map.values()];
|
||||
merged.sort((a, b) => {
|
||||
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
||||
@@ -619,7 +961,16 @@ export default function BoardDetailPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [boardId, isLiveFeedOpen, isSignedIn]);
|
||||
}, [
|
||||
agents,
|
||||
approvals,
|
||||
boardId,
|
||||
chatMessages,
|
||||
isLiveFeedOpen,
|
||||
isLoading,
|
||||
isSignedIn,
|
||||
tasks,
|
||||
]);
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -831,7 +1182,7 @@ export default function BoardDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!isPageActive) return;
|
||||
if (!isSignedIn || !boardId || !board) return;
|
||||
if (!isChatOpen) return;
|
||||
if (!isChatOpen && !isLiveFeedOpen) return;
|
||||
let isCancelled = false;
|
||||
const abortController = new AbortController();
|
||||
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
|
||||
@@ -891,6 +1242,7 @@ export default function BoardDetailPage() {
|
||||
memory?: BoardChatMessage;
|
||||
};
|
||||
if (payload.memory?.tags?.includes("chat")) {
|
||||
pushLiveFeed(toLiveFeedFromBoardChat(payload.memory));
|
||||
setChatMessages((prev) => {
|
||||
const exists = prev.some(
|
||||
(item) => item.id === payload.memory?.id,
|
||||
@@ -936,126 +1288,15 @@ export default function BoardDetailPage() {
|
||||
window.clearTimeout(reconnectTimeout);
|
||||
}
|
||||
};
|
||||
}, [board, boardId, isChatOpen, isPageActive, isSignedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPageActive) return;
|
||||
if (!isLiveFeedOpen) return;
|
||||
if (!isSignedIn || !boardId) return;
|
||||
let isCancelled = false;
|
||||
const abortController = new AbortController();
|
||||
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
|
||||
let reconnectTimeout: number | undefined;
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
const since = (() => {
|
||||
let latestTime = 0;
|
||||
liveFeedRef.current.forEach((comment) => {
|
||||
const time = apiDatetimeToMs(comment.created_at);
|
||||
if (time !== null && time > latestTime) {
|
||||
latestTime = time;
|
||||
}
|
||||
});
|
||||
return latestTime ? new Date(latestTime).toISOString() : null;
|
||||
})();
|
||||
|
||||
const streamResult =
|
||||
await streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet(
|
||||
{
|
||||
board_id: boardId,
|
||||
since: since ?? null,
|
||||
},
|
||||
{
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
if (streamResult.status !== 200) {
|
||||
throw new Error("Unable to connect live feed stream.");
|
||||
}
|
||||
const response = streamResult.data as Response;
|
||||
if (!(response instanceof Response) || !response.body) {
|
||||
throw new Error("Unable to connect live feed stream.");
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (!isCancelled) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value && value.length) {
|
||||
backoff.reset();
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = buffer.replace(/\r\n/g, "\n");
|
||||
let boundary = buffer.indexOf("\n\n");
|
||||
while (boundary !== -1) {
|
||||
const raw = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
const lines = raw.split("\n");
|
||||
let eventType = "message";
|
||||
let data = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event:")) {
|
||||
eventType = line.slice(6).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
data += line.slice(5).trim();
|
||||
}
|
||||
}
|
||||
if (eventType === "comment" && data) {
|
||||
try {
|
||||
const payload = JSON.parse(data) as {
|
||||
comment?: {
|
||||
id: string;
|
||||
created_at: string;
|
||||
message?: string | null;
|
||||
agent_id?: string | null;
|
||||
task_id?: string | null;
|
||||
};
|
||||
};
|
||||
if (payload.comment) {
|
||||
pushLiveFeed({
|
||||
id: payload.comment.id,
|
||||
created_at: payload.comment.created_at,
|
||||
message: payload.comment.message ?? null,
|
||||
agent_id: payload.comment.agent_id ?? null,
|
||||
task_id: payload.comment.task_id ?? null,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
}
|
||||
boundary = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Reconnect handled below.
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
if (reconnectTimeout !== undefined) {
|
||||
window.clearTimeout(reconnectTimeout);
|
||||
}
|
||||
const delay = backoff.nextDelayMs();
|
||||
reconnectTimeout = window.setTimeout(() => {
|
||||
reconnectTimeout = undefined;
|
||||
void connect();
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
abortController.abort();
|
||||
if (reconnectTimeout !== undefined) {
|
||||
window.clearTimeout(reconnectTimeout);
|
||||
}
|
||||
};
|
||||
}, [boardId, isLiveFeedOpen, isPageActive, isSignedIn, pushLiveFeed]);
|
||||
}, [
|
||||
board,
|
||||
boardId,
|
||||
isChatOpen,
|
||||
isLiveFeedOpen,
|
||||
isPageActive,
|
||||
isSignedIn,
|
||||
pushLiveFeed,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPageActive) return;
|
||||
@@ -1129,6 +1370,13 @@ export default function BoardDetailPage() {
|
||||
};
|
||||
if (payload.approval) {
|
||||
const normalized = normalizeApproval(payload.approval);
|
||||
const previousApproval =
|
||||
approvalsRef.current.find(
|
||||
(item) => item.id === normalized.id,
|
||||
) ?? null;
|
||||
pushLiveFeed(
|
||||
toLiveFeedFromApproval(normalized, previousApproval),
|
||||
);
|
||||
setApprovals((prev) => {
|
||||
const index = prev.findIndex(
|
||||
(item) => item.id === normalized.id,
|
||||
@@ -1201,7 +1449,7 @@ export default function BoardDetailPage() {
|
||||
window.clearTimeout(reconnectTimeout);
|
||||
}
|
||||
};
|
||||
}, [board, boardId, isPageActive, isSignedIn]);
|
||||
}, [board, boardId, isPageActive, isSignedIn, pushLiveFeed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTask) {
|
||||
@@ -1279,14 +1527,22 @@ export default function BoardDetailPage() {
|
||||
try {
|
||||
const payload = JSON.parse(data) as {
|
||||
type?: string;
|
||||
activity?: ActivityEventRead;
|
||||
task?: TaskRead;
|
||||
comment?: TaskCommentRead;
|
||||
};
|
||||
const liveEvent = payload.activity
|
||||
? toLiveFeedFromActivity(payload.activity)
|
||||
: payload.type === "task.comment" && payload.comment
|
||||
? toLiveFeedFromComment(payload.comment)
|
||||
: null;
|
||||
if (liveEvent) {
|
||||
pushLiveFeed(liveEvent);
|
||||
}
|
||||
if (
|
||||
payload.comment?.task_id &&
|
||||
payload.type === "task.comment"
|
||||
) {
|
||||
pushLiveFeed(payload.comment);
|
||||
setComments((prev) => {
|
||||
if (
|
||||
selectedTaskIdRef.current !== payload.comment?.task_id
|
||||
@@ -1454,6 +1710,17 @@ export default function BoardDetailPage() {
|
||||
const payload = JSON.parse(data) as { agent?: AgentRead };
|
||||
if (payload.agent) {
|
||||
const normalized = normalizeAgent(payload.agent);
|
||||
const previousAgent =
|
||||
agentsRef.current.find(
|
||||
(item) => item.id === normalized.id,
|
||||
) ?? null;
|
||||
const liveEvent = toLiveFeedFromAgentUpdate(
|
||||
normalized,
|
||||
previousAgent,
|
||||
);
|
||||
if (liveEvent) {
|
||||
pushLiveFeed(liveEvent);
|
||||
}
|
||||
setAgents((prev) => {
|
||||
const index = prev.findIndex(
|
||||
(item) => item.id === normalized.id,
|
||||
@@ -1500,7 +1767,7 @@ export default function BoardDetailPage() {
|
||||
window.clearTimeout(reconnectTimeout);
|
||||
}
|
||||
};
|
||||
}, [board, boardId, isOrgAdmin, isPageActive, isSignedIn]);
|
||||
}, [board, boardId, isOrgAdmin, isPageActive, isSignedIn, pushLiveFeed]);
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle("");
|
||||
@@ -1568,6 +1835,7 @@ export default function BoardDetailPage() {
|
||||
}
|
||||
const created = result.data;
|
||||
if (created.tags?.includes("chat")) {
|
||||
pushLiveFeed(toLiveFeedFromBoardChat(created));
|
||||
setChatMessages((prev) => {
|
||||
const exists = prev.some((item) => item.id === created.id);
|
||||
if (exists) return prev;
|
||||
@@ -1586,7 +1854,7 @@ export default function BoardDetailPage() {
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
},
|
||||
[boardId, isSignedIn],
|
||||
[boardId, isSignedIn, pushLiveFeed],
|
||||
);
|
||||
|
||||
const handleSendChat = useCallback(
|
||||
@@ -3237,7 +3505,7 @@ export default function BoardDetailPage() {
|
||||
Live feed
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
Realtime task comments across this board.
|
||||
Realtime task, approval, agent, and board-chat activity.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -3258,30 +3526,36 @@ export default function BoardDetailPage() {
|
||||
</div>
|
||||
) : orderedLiveFeed.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
Waiting for new comments…
|
||||
Waiting for new activity…
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{orderedLiveFeed.map((comment) => {
|
||||
const taskId = comment.task_id;
|
||||
const authorAgent = comment.agent_id
|
||||
? (agents.find((agent) => agent.id === comment.agent_id) ??
|
||||
{orderedLiveFeed.map((item) => {
|
||||
const taskId = item.task_id;
|
||||
const authorAgent = item.agent_id
|
||||
? (agents.find((agent) => agent.id === item.agent_id) ??
|
||||
null)
|
||||
: null;
|
||||
const authorName = authorAgent ? authorAgent.name : "Admin";
|
||||
const authorName =
|
||||
item.actor_name?.trim() ||
|
||||
(authorAgent ? authorAgent.name : "Admin");
|
||||
const authorRole = authorAgent
|
||||
? agentRoleLabel(authorAgent)
|
||||
: null;
|
||||
const authorAvatar = authorAgent
|
||||
? agentAvatarLabel(authorAgent)
|
||||
: "A";
|
||||
: (authorName[0] ?? "A").toUpperCase();
|
||||
return (
|
||||
<LiveFeedCard
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
isNew={Boolean(liveFeedFlashIds[comment.id])}
|
||||
key={item.id}
|
||||
item={item}
|
||||
isNew={Boolean(liveFeedFlashIds[item.id])}
|
||||
taskTitle={
|
||||
taskId ? (taskTitleById.get(taskId) ?? "Task") : "Task"
|
||||
item.title
|
||||
? item.title
|
||||
: taskId
|
||||
? (taskTitleById.get(taskId) ?? "Unknown task")
|
||||
: "Activity"
|
||||
}
|
||||
authorName={authorName}
|
||||
authorRole={authorRole}
|
||||
|
||||
@@ -140,9 +140,20 @@ const formatRubricTooltipValue = (
|
||||
);
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const payloadAtPath = (payload: Approval["payload"], path: string[]) => {
|
||||
let current: unknown = payload;
|
||||
for (const key of path) {
|
||||
if (!isRecord(current)) return null;
|
||||
current = current[key];
|
||||
}
|
||||
return current ?? null;
|
||||
};
|
||||
|
||||
const payloadValue = (payload: Approval["payload"], key: string) => {
|
||||
if (!payload) return null;
|
||||
const value = payload[key as keyof typeof payload];
|
||||
const value = payloadAtPath(payload, [key]);
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
@@ -150,12 +161,59 @@ const payloadValue = (payload: Approval["payload"], key: string) => {
|
||||
};
|
||||
|
||||
const payloadValues = (payload: Approval["payload"], key: string) => {
|
||||
if (!payload) return [];
|
||||
const value = payload[key as keyof typeof payload];
|
||||
const value = payloadAtPath(payload, [key]);
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
};
|
||||
|
||||
const payloadNestedValue = (payload: Approval["payload"], path: string[]) => {
|
||||
const value = payloadAtPath(payload, path);
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const payloadNestedValues = (payload: Approval["payload"], path: string[]) => {
|
||||
const value = payloadAtPath(payload, path);
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
};
|
||||
|
||||
const payloadFirstLinkedTaskValue = (
|
||||
payload: Approval["payload"],
|
||||
key: "title" | "description",
|
||||
) => {
|
||||
const tasks = payloadAtPath(payload, ["linked_request", "tasks"]);
|
||||
if (!Array.isArray(tasks)) return null;
|
||||
for (const task of tasks) {
|
||||
if (!isRecord(task)) continue;
|
||||
const value = task[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeRubricScores = (raw: unknown): Record<string, number> => {
|
||||
if (!isRecord(raw)) return {};
|
||||
const entries = Object.entries(raw).flatMap(([key, value]) => {
|
||||
const numeric =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
if (!Number.isFinite(numeric)) return [];
|
||||
return [[key, numeric] as const];
|
||||
});
|
||||
return Object.fromEntries(entries);
|
||||
};
|
||||
|
||||
const payloadRubricScores = (payload: Approval["payload"]) =>
|
||||
normalizeRubricScores(payloadAtPath(payload, ["analytics", "rubric_scores"]));
|
||||
|
||||
const approvalTaskIds = (approval: Approval) => {
|
||||
const payload = approval.payload ?? {};
|
||||
const linkedTaskIds = (approval as Approval & { task_ids?: string[] | null })
|
||||
@@ -170,6 +228,10 @@ const approvalTaskIds = (approval: Approval) => {
|
||||
...payloadValues(payload, "task_ids"),
|
||||
...payloadValues(payload, "taskIds"),
|
||||
...payloadValues(payload, "taskIDs"),
|
||||
...payloadNestedValues(payload, ["linked_request", "task_ids"]),
|
||||
...payloadNestedValues(payload, ["linked_request", "taskIds"]),
|
||||
...payloadNestedValues(payload, ["linkedRequest", "task_ids"]),
|
||||
...payloadNestedValues(payload, ["linkedRequest", "taskIds"]),
|
||||
...(singleTaskId ? [singleTaskId] : []),
|
||||
];
|
||||
return [...new Set(merged)];
|
||||
@@ -181,10 +243,20 @@ const approvalSummary = (approval: Approval, boardLabel?: string | null) => {
|
||||
const taskId = taskIds[0] ?? null;
|
||||
const assignedAgentId =
|
||||
payloadValue(payload, "assigned_agent_id") ??
|
||||
payloadValue(payload, "assignedAgentId");
|
||||
const reason = payloadValue(payload, "reason");
|
||||
const title = payloadValue(payload, "title");
|
||||
const description = payloadValue(payload, "description");
|
||||
payloadValue(payload, "assignedAgentId") ??
|
||||
payloadNestedValue(payload, ["assignment", "agent_id"]) ??
|
||||
payloadNestedValue(payload, ["assignment", "agentId"]);
|
||||
const reason =
|
||||
payloadValue(payload, "reason") ??
|
||||
payloadNestedValue(payload, ["decision", "reason"]);
|
||||
const title =
|
||||
payloadValue(payload, "title") ??
|
||||
payloadNestedValue(payload, ["task", "title"]) ??
|
||||
payloadFirstLinkedTaskValue(payload, "title");
|
||||
const description =
|
||||
payloadValue(payload, "description") ??
|
||||
payloadNestedValue(payload, ["task", "description"]) ??
|
||||
payloadFirstLinkedTaskValue(payload, "description");
|
||||
const role = payloadValue(payload, "role");
|
||||
const isAssign = approval.action_type.includes("assign");
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
@@ -517,14 +589,20 @@ export function BoardApprovalsPanel({
|
||||
if (normalized === "assignee") return false;
|
||||
return true;
|
||||
});
|
||||
const rubricEntries = Object.entries(
|
||||
selectedApproval.rubric_scores ?? {},
|
||||
).map(([key, value]) => ({
|
||||
label: key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase()),
|
||||
value,
|
||||
}));
|
||||
const rubricScoreSource =
|
||||
Object.keys(
|
||||
normalizeRubricScores(selectedApproval.rubric_scores),
|
||||
).length > 0
|
||||
? normalizeRubricScores(selectedApproval.rubric_scores)
|
||||
: payloadRubricScores(selectedApproval.payload);
|
||||
const rubricEntries = Object.entries(rubricScoreSource).map(
|
||||
([key, value]) => ({
|
||||
label: key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase()),
|
||||
value,
|
||||
}),
|
||||
);
|
||||
const rubricTotal = rubricEntries.reduce(
|
||||
(total, entry) => total + entry.value,
|
||||
0,
|
||||
|
||||
@@ -55,9 +55,9 @@ describe("ActivityFeed", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Waiting for new comments…")).toBeInTheDocument();
|
||||
expect(screen.getByText("Waiting for new activity…")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("When agents post updates, they will show up here."),
|
||||
screen.getByText("When updates happen, they will show up here."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -34,10 +34,10 @@ export function ActivityFeed<TItem extends FeedItem>({
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-10 text-center shadow-sm">
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
Waiting for new comments…
|
||||
Waiting for new activity…
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
When agents post updates, they will show up here.
|
||||
When updates happen, they will show up here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user