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.schemas.pagination import DefaultLimitOffsetPage
|
||||||
from app.services.activity_log import record_activity
|
from app.services.activity_log import record_activity
|
||||||
from app.services.approval_task_links import (
|
from app.services.approval_task_links import (
|
||||||
lock_tasks_for_approval,
|
|
||||||
load_task_ids_by_approval,
|
load_task_ids_by_approval,
|
||||||
|
lock_tasks_for_approval,
|
||||||
normalize_task_ids,
|
normalize_task_ids,
|
||||||
pending_approval_conflicts_by_task,
|
pending_approval_conflicts_by_task,
|
||||||
replace_approval_task_links,
|
replace_approval_task_links,
|
||||||
@@ -408,7 +408,9 @@ async def update_approval(
|
|||||||
if "status" in updates:
|
if "status" in updates:
|
||||||
target_status = updates["status"]
|
target_status = updates["status"]
|
||||||
if target_status == "pending" and prior_status != "pending":
|
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)
|
approval_task_ids = task_ids_by_approval.get(approval.id)
|
||||||
if not approval_task_ids and approval.task_id is not None:
|
if not approval_task_ids and approval.task_id is not None:
|
||||||
approval_task_ids = [approval.task_id]
|
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_dependencies import TaskDependency
|
||||||
from app.models.task_fingerprints import TaskFingerprint
|
from app.models.task_fingerprints import TaskFingerprint
|
||||||
from app.models.tasks import Task
|
from app.models.tasks import Task
|
||||||
|
from app.schemas.activity_events import ActivityEventRead
|
||||||
from app.schemas.common import OkResponse
|
from app.schemas.common import OkResponse
|
||||||
from app.schemas.errors import BlockedTaskError
|
from app.schemas.errors import BlockedTaskError
|
||||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||||
@@ -648,7 +649,10 @@ def _task_event_payload(
|
|||||||
deps_map: dict[UUID, list[UUID]],
|
deps_map: dict[UUID, list[UUID]],
|
||||||
dep_status: dict[UUID, str],
|
dep_status: dict[UUID, str],
|
||||||
) -> dict[str, object]:
|
) -> 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":
|
if event.event_type == "task.comment":
|
||||||
payload["comment"] = _serialize_comment(event)
|
payload["comment"] = _serialize_comment(event)
|
||||||
return payload
|
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_statement = legacy_statement.where(col(Approval.id) != exclude_approval_id)
|
||||||
legacy_rows = list(await session.exec(legacy_statement))
|
legacy_rows = list(await session.exec(legacy_statement))
|
||||||
|
|
||||||
for task_id, approval_id, _created_at in legacy_rows:
|
for legacy_task_id, approval_id, _created_at in legacy_rows:
|
||||||
if task_id is None:
|
if legacy_task_id is None:
|
||||||
continue
|
continue
|
||||||
conflicts.setdefault(task_id, approval_id)
|
conflicts.setdefault(legacy_task_id, approval_id)
|
||||||
|
|
||||||
return conflicts
|
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]:
|
async def exec(self, _stmt: object) -> list[object]:
|
||||||
agents = []
|
agents = []
|
||||||
for i in range(50):
|
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
|
return agents
|
||||||
|
|
||||||
calls = {"n": 0}
|
calls = {"n": 0}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
import pytest
|
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.activity_events import ActivityEvent
|
||||||
from app.models.tasks import Task
|
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():
|
def test_coerce_task_event_rows_rejects_invalid_values():
|
||||||
with pytest.raises(TypeError, match="Expected \\(ActivityEvent, Task \\| None\\) rows"):
|
with pytest.raises(TypeError, match="Expected \\(ActivityEvent, Task \\| None\\) rows"):
|
||||||
_coerce_task_event_rows([("bad", "row")])
|
_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,
|
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
|
||||||
updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
|
updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
|
||||||
} from "@/api/generated/approvals/approvals";
|
} from "@/api/generated/approvals/approvals";
|
||||||
import {
|
import { listActivityApiV1ActivityGet } from "@/api/generated/activity/activity";
|
||||||
listTaskCommentFeedApiV1ActivityTaskCommentsGet,
|
|
||||||
streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet,
|
|
||||||
} from "@/api/generated/activity/activity";
|
|
||||||
import {
|
import {
|
||||||
getBoardGroupSnapshotApiV1BoardsBoardIdGroupSnapshotGet,
|
getBoardGroupSnapshotApiV1BoardsBoardIdGroupSnapshotGet,
|
||||||
getBoardSnapshotApiV1BoardsBoardIdSnapshotGet,
|
getBoardSnapshotApiV1BoardsBoardIdSnapshotGet,
|
||||||
@@ -83,6 +80,7 @@ import type {
|
|||||||
BoardGroupSnapshot,
|
BoardGroupSnapshot,
|
||||||
BoardMemoryRead,
|
BoardMemoryRead,
|
||||||
BoardRead,
|
BoardRead,
|
||||||
|
ActivityEventRead,
|
||||||
OrganizationMemberRead,
|
OrganizationMemberRead,
|
||||||
TaskCardRead,
|
TaskCardRead,
|
||||||
TaskCommentRead,
|
TaskCommentRead,
|
||||||
@@ -115,6 +113,295 @@ type Approval = ApprovalRead & { status: string };
|
|||||||
|
|
||||||
type BoardChatMessage = BoardMemoryRead;
|
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 => ({
|
const normalizeTask = (task: TaskCardRead): Task => ({
|
||||||
...task,
|
...task,
|
||||||
status: task.status ?? "inbox",
|
status: task.status ?? "inbox",
|
||||||
@@ -271,7 +558,7 @@ const ChatMessageCard = memo(function ChatMessageCard({
|
|||||||
ChatMessageCard.displayName = "ChatMessageCard";
|
ChatMessageCard.displayName = "ChatMessageCard";
|
||||||
|
|
||||||
const LiveFeedCard = memo(function LiveFeedCard({
|
const LiveFeedCard = memo(function LiveFeedCard({
|
||||||
comment,
|
item,
|
||||||
taskTitle,
|
taskTitle,
|
||||||
authorName,
|
authorName,
|
||||||
authorRole,
|
authorRole,
|
||||||
@@ -279,7 +566,7 @@ const LiveFeedCard = memo(function LiveFeedCard({
|
|||||||
onViewTask,
|
onViewTask,
|
||||||
isNew,
|
isNew,
|
||||||
}: {
|
}: {
|
||||||
comment: TaskComment;
|
item: LiveFeedItem;
|
||||||
taskTitle: string;
|
taskTitle: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
authorRole?: string | null;
|
authorRole?: string | null;
|
||||||
@@ -287,7 +574,9 @@ const LiveFeedCard = memo(function LiveFeedCard({
|
|||||||
onViewTask?: () => void;
|
onViewTask?: () => void;
|
||||||
isNew?: boolean;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -323,19 +612,16 @@ const LiveFeedCard = memo(function LiveFeedCard({
|
|||||||
>
|
>
|
||||||
{taskTitle}
|
{taskTitle}
|
||||||
</button>
|
</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>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
|
<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>
|
<span className="font-medium text-slate-700">{authorName}</span>
|
||||||
{authorRole ? (
|
{authorRole ? (
|
||||||
<>
|
<>
|
||||||
@@ -345,7 +631,7 @@ const LiveFeedCard = memo(function LiveFeedCard({
|
|||||||
) : null}
|
) : null}
|
||||||
<span className="text-slate-300">·</span>
|
<span className="text-slate-300">·</span>
|
||||||
<span className="text-slate-400">
|
<span className="text-slate-400">
|
||||||
{formatShortTimestamp(comment.created_at)}
|
{formatShortTimestamp(item.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -413,8 +699,8 @@ export default function BoardDetailPage() {
|
|||||||
const selectedTaskIdRef = useRef<string | null>(null);
|
const selectedTaskIdRef = useRef<string | null>(null);
|
||||||
const openedTaskIdFromUrlRef = useRef<string | null>(null);
|
const openedTaskIdFromUrlRef = useRef<string | null>(null);
|
||||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||||
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
|
const [liveFeed, setLiveFeed] = useState<LiveFeedItem[]>([]);
|
||||||
const liveFeedRef = useRef<TaskComment[]>([]);
|
const liveFeedRef = useRef<LiveFeedItem[]>([]);
|
||||||
const liveFeedFlashTimersRef = useRef<Record<string, number>>({});
|
const liveFeedFlashTimersRef = useRef<Record<string, number>>({});
|
||||||
const [liveFeedFlashIds, setLiveFeedFlashIds] = useState<
|
const [liveFeedFlashIds, setLiveFeedFlashIds] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
@@ -467,15 +753,15 @@ export default function BoardDetailPage() {
|
|||||||
const isLiveFeedOpenRef = useRef(false);
|
const isLiveFeedOpenRef = useRef(false);
|
||||||
const toastIdRef = useRef(0);
|
const toastIdRef = useRef(0);
|
||||||
const toastTimersRef = useRef<Record<number, number>>({});
|
const toastTimersRef = useRef<Record<number, number>>({});
|
||||||
const pushLiveFeed = useCallback((comment: TaskComment) => {
|
const pushLiveFeed = useCallback((item: LiveFeedItem) => {
|
||||||
const alreadySeen = liveFeedRef.current.some(
|
const alreadySeen = liveFeedRef.current.some(
|
||||||
(item) => item.id === comment.id,
|
(existing) => existing.id === item.id,
|
||||||
);
|
);
|
||||||
setLiveFeed((prev) => {
|
setLiveFeed((prev) => {
|
||||||
if (prev.some((item) => item.id === comment.id)) {
|
if (prev.some((existing) => existing.id === item.id)) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const next = [comment, ...prev];
|
const next = [item, ...prev];
|
||||||
return next.slice(0, 50);
|
return next.slice(0, 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -483,20 +769,20 @@ export default function BoardDetailPage() {
|
|||||||
if (!isLiveFeedOpenRef.current) return;
|
if (!isLiveFeedOpenRef.current) return;
|
||||||
|
|
||||||
setLiveFeedFlashIds((prev) =>
|
setLiveFeedFlashIds((prev) =>
|
||||||
prev[comment.id] ? prev : { ...prev, [comment.id]: true },
|
prev[item.id] ? prev : { ...prev, [item.id]: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const existingTimer = liveFeedFlashTimersRef.current[comment.id];
|
const existingTimer = liveFeedFlashTimersRef.current[item.id];
|
||||||
if (existingTimer !== undefined) {
|
if (existingTimer !== undefined) {
|
||||||
window.clearTimeout(existingTimer);
|
window.clearTimeout(existingTimer);
|
||||||
}
|
}
|
||||||
liveFeedFlashTimersRef.current[comment.id] = window.setTimeout(() => {
|
liveFeedFlashTimersRef.current[item.id] = window.setTimeout(() => {
|
||||||
delete liveFeedFlashTimersRef.current[comment.id];
|
delete liveFeedFlashTimersRef.current[item.id];
|
||||||
setLiveFeedFlashIds((prev) => {
|
setLiveFeedFlashIds((prev) => {
|
||||||
if (!prev[comment.id]) return prev;
|
if (!prev[item.id]) return prev;
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[comment.id];
|
delete next[item.id];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, 2200);
|
}, 2200);
|
||||||
@@ -566,6 +852,7 @@ export default function BoardDetailPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLiveFeedOpen) return;
|
if (!isLiveFeedOpen) return;
|
||||||
if (!isSignedIn || !boardId) return;
|
if (!isSignedIn || !boardId) return;
|
||||||
|
if (isLoading) return;
|
||||||
if (liveFeedHistoryLoadedRef.current) return;
|
if (liveFeedHistoryLoadedRef.current) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -574,28 +861,83 @@ export default function BoardDetailPage() {
|
|||||||
|
|
||||||
const fetchHistory = async () => {
|
const fetchHistory = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await listTaskCommentFeedApiV1ActivityTaskCommentsGet({
|
const sourceTasks =
|
||||||
board_id: boardId,
|
tasksRef.current.length > 0 ? tasksRef.current : tasks;
|
||||||
limit: 200,
|
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 (cancelled) return;
|
||||||
if (result.status !== 200) {
|
if (result.status !== 200) {
|
||||||
throw new Error("Unable to load live feed.");
|
throw new Error("Unable to load live feed.");
|
||||||
}
|
}
|
||||||
const items = result.data.items ?? [];
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
liveFeedHistoryLoadedRef.current = true;
|
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) => {
|
setLiveFeed((prev) => {
|
||||||
const map = new Map<string, TaskComment>();
|
const map = new Map<string, LiveFeedItem>();
|
||||||
[...prev, ...mapped].forEach((item) => map.set(item.id, item));
|
[...prev, ...collected].forEach((item) => map.set(item.id, item));
|
||||||
const merged = [...map.values()];
|
const merged = [...map.values()];
|
||||||
merged.sort((a, b) => {
|
merged.sort((a, b) => {
|
||||||
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
|
||||||
@@ -619,7 +961,16 @@ export default function BoardDetailPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [boardId, isLiveFeedOpen, isSignedIn]);
|
}, [
|
||||||
|
agents,
|
||||||
|
approvals,
|
||||||
|
boardId,
|
||||||
|
chatMessages,
|
||||||
|
isLiveFeedOpen,
|
||||||
|
isLoading,
|
||||||
|
isSignedIn,
|
||||||
|
tasks,
|
||||||
|
]);
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -831,7 +1182,7 @@ export default function BoardDetailPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPageActive) return;
|
if (!isPageActive) return;
|
||||||
if (!isSignedIn || !boardId || !board) return;
|
if (!isSignedIn || !boardId || !board) return;
|
||||||
if (!isChatOpen) return;
|
if (!isChatOpen && !isLiveFeedOpen) return;
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
|
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
|
||||||
@@ -891,6 +1242,7 @@ export default function BoardDetailPage() {
|
|||||||
memory?: BoardChatMessage;
|
memory?: BoardChatMessage;
|
||||||
};
|
};
|
||||||
if (payload.memory?.tags?.includes("chat")) {
|
if (payload.memory?.tags?.includes("chat")) {
|
||||||
|
pushLiveFeed(toLiveFeedFromBoardChat(payload.memory));
|
||||||
setChatMessages((prev) => {
|
setChatMessages((prev) => {
|
||||||
const exists = prev.some(
|
const exists = prev.some(
|
||||||
(item) => item.id === payload.memory?.id,
|
(item) => item.id === payload.memory?.id,
|
||||||
@@ -936,126 +1288,15 @@ export default function BoardDetailPage() {
|
|||||||
window.clearTimeout(reconnectTimeout);
|
window.clearTimeout(reconnectTimeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [board, boardId, isChatOpen, isPageActive, isSignedIn]);
|
}, [
|
||||||
|
board,
|
||||||
useEffect(() => {
|
boardId,
|
||||||
if (!isPageActive) return;
|
isChatOpen,
|
||||||
if (!isLiveFeedOpen) return;
|
isLiveFeedOpen,
|
||||||
if (!isSignedIn || !boardId) return;
|
isPageActive,
|
||||||
let isCancelled = false;
|
isSignedIn,
|
||||||
const abortController = new AbortController();
|
pushLiveFeed,
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPageActive) return;
|
if (!isPageActive) return;
|
||||||
@@ -1129,6 +1370,13 @@ export default function BoardDetailPage() {
|
|||||||
};
|
};
|
||||||
if (payload.approval) {
|
if (payload.approval) {
|
||||||
const normalized = normalizeApproval(payload.approval);
|
const normalized = normalizeApproval(payload.approval);
|
||||||
|
const previousApproval =
|
||||||
|
approvalsRef.current.find(
|
||||||
|
(item) => item.id === normalized.id,
|
||||||
|
) ?? null;
|
||||||
|
pushLiveFeed(
|
||||||
|
toLiveFeedFromApproval(normalized, previousApproval),
|
||||||
|
);
|
||||||
setApprovals((prev) => {
|
setApprovals((prev) => {
|
||||||
const index = prev.findIndex(
|
const index = prev.findIndex(
|
||||||
(item) => item.id === normalized.id,
|
(item) => item.id === normalized.id,
|
||||||
@@ -1201,7 +1449,7 @@ export default function BoardDetailPage() {
|
|||||||
window.clearTimeout(reconnectTimeout);
|
window.clearTimeout(reconnectTimeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [board, boardId, isPageActive, isSignedIn]);
|
}, [board, boardId, isPageActive, isSignedIn, pushLiveFeed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedTask) {
|
if (!selectedTask) {
|
||||||
@@ -1279,14 +1527,22 @@ export default function BoardDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const payload = JSON.parse(data) as {
|
const payload = JSON.parse(data) as {
|
||||||
type?: string;
|
type?: string;
|
||||||
|
activity?: ActivityEventRead;
|
||||||
task?: TaskRead;
|
task?: TaskRead;
|
||||||
comment?: TaskCommentRead;
|
comment?: TaskCommentRead;
|
||||||
};
|
};
|
||||||
|
const liveEvent = payload.activity
|
||||||
|
? toLiveFeedFromActivity(payload.activity)
|
||||||
|
: payload.type === "task.comment" && payload.comment
|
||||||
|
? toLiveFeedFromComment(payload.comment)
|
||||||
|
: null;
|
||||||
|
if (liveEvent) {
|
||||||
|
pushLiveFeed(liveEvent);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
payload.comment?.task_id &&
|
payload.comment?.task_id &&
|
||||||
payload.type === "task.comment"
|
payload.type === "task.comment"
|
||||||
) {
|
) {
|
||||||
pushLiveFeed(payload.comment);
|
|
||||||
setComments((prev) => {
|
setComments((prev) => {
|
||||||
if (
|
if (
|
||||||
selectedTaskIdRef.current !== payload.comment?.task_id
|
selectedTaskIdRef.current !== payload.comment?.task_id
|
||||||
@@ -1454,6 +1710,17 @@ export default function BoardDetailPage() {
|
|||||||
const payload = JSON.parse(data) as { agent?: AgentRead };
|
const payload = JSON.parse(data) as { agent?: AgentRead };
|
||||||
if (payload.agent) {
|
if (payload.agent) {
|
||||||
const normalized = normalizeAgent(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) => {
|
setAgents((prev) => {
|
||||||
const index = prev.findIndex(
|
const index = prev.findIndex(
|
||||||
(item) => item.id === normalized.id,
|
(item) => item.id === normalized.id,
|
||||||
@@ -1500,7 +1767,7 @@ export default function BoardDetailPage() {
|
|||||||
window.clearTimeout(reconnectTimeout);
|
window.clearTimeout(reconnectTimeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [board, boardId, isOrgAdmin, isPageActive, isSignedIn]);
|
}, [board, boardId, isOrgAdmin, isPageActive, isSignedIn, pushLiveFeed]);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
@@ -1568,6 +1835,7 @@ export default function BoardDetailPage() {
|
|||||||
}
|
}
|
||||||
const created = result.data;
|
const created = result.data;
|
||||||
if (created.tags?.includes("chat")) {
|
if (created.tags?.includes("chat")) {
|
||||||
|
pushLiveFeed(toLiveFeedFromBoardChat(created));
|
||||||
setChatMessages((prev) => {
|
setChatMessages((prev) => {
|
||||||
const exists = prev.some((item) => item.id === created.id);
|
const exists = prev.some((item) => item.id === created.id);
|
||||||
if (exists) return prev;
|
if (exists) return prev;
|
||||||
@@ -1586,7 +1854,7 @@ export default function BoardDetailPage() {
|
|||||||
return { ok: false, error: message };
|
return { ok: false, error: message };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[boardId, isSignedIn],
|
[boardId, isSignedIn, pushLiveFeed],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSendChat = useCallback(
|
const handleSendChat = useCallback(
|
||||||
@@ -3237,7 +3505,7 @@ export default function BoardDetailPage() {
|
|||||||
Live feed
|
Live feed
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -3258,30 +3526,36 @@ export default function BoardDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : orderedLiveFeed.length === 0 ? (
|
) : orderedLiveFeed.length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Waiting for new comments…
|
Waiting for new activity…
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{orderedLiveFeed.map((comment) => {
|
{orderedLiveFeed.map((item) => {
|
||||||
const taskId = comment.task_id;
|
const taskId = item.task_id;
|
||||||
const authorAgent = comment.agent_id
|
const authorAgent = item.agent_id
|
||||||
? (agents.find((agent) => agent.id === comment.agent_id) ??
|
? (agents.find((agent) => agent.id === item.agent_id) ??
|
||||||
null)
|
null)
|
||||||
: null;
|
: null;
|
||||||
const authorName = authorAgent ? authorAgent.name : "Admin";
|
const authorName =
|
||||||
|
item.actor_name?.trim() ||
|
||||||
|
(authorAgent ? authorAgent.name : "Admin");
|
||||||
const authorRole = authorAgent
|
const authorRole = authorAgent
|
||||||
? agentRoleLabel(authorAgent)
|
? agentRoleLabel(authorAgent)
|
||||||
: null;
|
: null;
|
||||||
const authorAvatar = authorAgent
|
const authorAvatar = authorAgent
|
||||||
? agentAvatarLabel(authorAgent)
|
? agentAvatarLabel(authorAgent)
|
||||||
: "A";
|
: (authorName[0] ?? "A").toUpperCase();
|
||||||
return (
|
return (
|
||||||
<LiveFeedCard
|
<LiveFeedCard
|
||||||
key={comment.id}
|
key={item.id}
|
||||||
comment={comment}
|
item={item}
|
||||||
isNew={Boolean(liveFeedFlashIds[comment.id])}
|
isNew={Boolean(liveFeedFlashIds[item.id])}
|
||||||
taskTitle={
|
taskTitle={
|
||||||
taskId ? (taskTitleById.get(taskId) ?? "Task") : "Task"
|
item.title
|
||||||
|
? item.title
|
||||||
|
: taskId
|
||||||
|
? (taskTitleById.get(taskId) ?? "Unknown task")
|
||||||
|
: "Activity"
|
||||||
}
|
}
|
||||||
authorName={authorName}
|
authorName={authorName}
|
||||||
authorRole={authorRole}
|
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) => {
|
const payloadValue = (payload: Approval["payload"], key: string) => {
|
||||||
if (!payload) return null;
|
const value = payloadAtPath(payload, [key]);
|
||||||
const value = payload[key as keyof typeof payload];
|
|
||||||
if (typeof value === "string" || typeof value === "number") {
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
@@ -150,12 +161,59 @@ const payloadValue = (payload: Approval["payload"], key: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const payloadValues = (payload: Approval["payload"], key: string) => {
|
const payloadValues = (payload: Approval["payload"], key: string) => {
|
||||||
if (!payload) return [];
|
const value = payloadAtPath(payload, [key]);
|
||||||
const value = payload[key as keyof typeof payload];
|
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.filter((item): item is string => typeof item === "string");
|
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 approvalTaskIds = (approval: Approval) => {
|
||||||
const payload = approval.payload ?? {};
|
const payload = approval.payload ?? {};
|
||||||
const linkedTaskIds = (approval as Approval & { task_ids?: string[] | null })
|
const linkedTaskIds = (approval as Approval & { task_ids?: string[] | null })
|
||||||
@@ -170,6 +228,10 @@ const approvalTaskIds = (approval: Approval) => {
|
|||||||
...payloadValues(payload, "task_ids"),
|
...payloadValues(payload, "task_ids"),
|
||||||
...payloadValues(payload, "taskIds"),
|
...payloadValues(payload, "taskIds"),
|
||||||
...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] : []),
|
...(singleTaskId ? [singleTaskId] : []),
|
||||||
];
|
];
|
||||||
return [...new Set(merged)];
|
return [...new Set(merged)];
|
||||||
@@ -181,10 +243,20 @@ const approvalSummary = (approval: Approval, boardLabel?: string | null) => {
|
|||||||
const taskId = taskIds[0] ?? null;
|
const taskId = taskIds[0] ?? null;
|
||||||
const assignedAgentId =
|
const assignedAgentId =
|
||||||
payloadValue(payload, "assigned_agent_id") ??
|
payloadValue(payload, "assigned_agent_id") ??
|
||||||
payloadValue(payload, "assignedAgentId");
|
payloadValue(payload, "assignedAgentId") ??
|
||||||
const reason = payloadValue(payload, "reason");
|
payloadNestedValue(payload, ["assignment", "agent_id"]) ??
|
||||||
const title = payloadValue(payload, "title");
|
payloadNestedValue(payload, ["assignment", "agentId"]);
|
||||||
const description = payloadValue(payload, "description");
|
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 role = payloadValue(payload, "role");
|
||||||
const isAssign = approval.action_type.includes("assign");
|
const isAssign = approval.action_type.includes("assign");
|
||||||
const rows: Array<{ label: string; value: string }> = [];
|
const rows: Array<{ label: string; value: string }> = [];
|
||||||
@@ -517,14 +589,20 @@ export function BoardApprovalsPanel({
|
|||||||
if (normalized === "assignee") return false;
|
if (normalized === "assignee") return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const rubricEntries = Object.entries(
|
const rubricScoreSource =
|
||||||
selectedApproval.rubric_scores ?? {},
|
Object.keys(
|
||||||
).map(([key, value]) => ({
|
normalizeRubricScores(selectedApproval.rubric_scores),
|
||||||
|
).length > 0
|
||||||
|
? normalizeRubricScores(selectedApproval.rubric_scores)
|
||||||
|
: payloadRubricScores(selectedApproval.payload);
|
||||||
|
const rubricEntries = Object.entries(rubricScoreSource).map(
|
||||||
|
([key, value]) => ({
|
||||||
label: key
|
label: key
|
||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
.replace(/\b\w/g, (char) => char.toUpperCase()),
|
.replace(/\b\w/g, (char) => char.toUpperCase()),
|
||||||
value,
|
value,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
const rubricTotal = rubricEntries.reduce(
|
const rubricTotal = rubricEntries.reduce(
|
||||||
(total, entry) => total + entry.value,
|
(total, entry) => total + entry.value,
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ describe("ActivityFeed", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Waiting for new comments…")).toBeInTheDocument();
|
expect(screen.getByText("Waiting for new activity…")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("When agents post updates, they will show up here."),
|
screen.getByText("When updates happen, they will show up here."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ export function ActivityFeed<TItem extends FeedItem>({
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-10 text-center shadow-sm">
|
<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">
|
<p className="text-sm font-medium text-slate-900">
|
||||||
Waiting for new comments…
|
Waiting for new activity…
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user