diff --git a/backend/app/api/activity.py b/backend/app/api/activity.py index 168fe90..37947af 100644 --- a/backend/app/api/activity.py +++ b/backend/app/api/activity.py @@ -1,19 +1,108 @@ from __future__ import annotations -from fastapi import APIRouter, Depends -from sqlalchemy import desc +import asyncio +import json +from collections import deque +from collections.abc import AsyncIterator, Sequence +from datetime import datetime, timezone +from typing import Any, cast +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy import asc, desc, func from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession +from sse_starlette.sse import EventSourceResponse -from app.api.deps import ActorContext, require_admin_or_agent +from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent +from app.core.auth import AuthContext +from app.core.time import utcnow from app.db.pagination import paginate -from app.db.session import get_session +from app.db.session import async_session_maker, get_session from app.models.activity_events import ActivityEvent -from app.schemas.activity_events import ActivityEventRead +from app.models.agents import Agent +from app.models.boards import Board +from app.models.tasks import Task +from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead from app.schemas.pagination import DefaultLimitOffsetPage router = APIRouter(prefix="/activity", tags=["activity"]) +SSE_SEEN_MAX = 2000 + + +def _parse_since(value: str | None) -> datetime | None: + if not value: + return None + normalized = value.strip() + if not normalized: + return None + normalized = normalized.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is not None: + return parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed + + +def _agent_role(agent: Agent | None) -> str | None: + if agent is None: + return None + profile = agent.identity_profile + if not isinstance(profile, dict): + return None + raw = profile.get("role") + if isinstance(raw, str): + role = raw.strip() + return role or None + return None + + +def _feed_item( + event: ActivityEvent, + task: Task, + board: Board, + agent: Agent | None, +) -> ActivityTaskCommentFeedItemRead: + return ActivityTaskCommentFeedItemRead( + id=event.id, + created_at=event.created_at, + message=event.message, + agent_id=event.agent_id, + agent_name=agent.name if agent else None, + agent_role=_agent_role(agent), + task_id=task.id, + task_title=task.title, + board_id=board.id, + board_name=board.name, + ) + + +async def _fetch_task_comment_events( + session: AsyncSession, + since: datetime, + *, + board_id: UUID | None = None, +) -> Sequence[tuple[ActivityEvent, Task, Board, Agent | None]]: + statement = ( + select(ActivityEvent, Task, Board, Agent) + .join(Task, col(ActivityEvent.task_id) == col(Task.id)) + .join(Board, col(Task.board_id) == col(Board.id)) + .outerjoin(Agent, col(ActivityEvent.agent_id) == col(Agent.id)) + .where(col(ActivityEvent.event_type) == "task.comment") + .where(col(ActivityEvent.created_at) >= since) + .where(func.length(func.trim(col(ActivityEvent.message))) > 0) + .order_by(asc(col(ActivityEvent.created_at))) + ) + if board_id is not None: + statement = statement.where(col(Task.board_id) == board_id) + return cast( + Sequence[tuple[ActivityEvent, Task, Board, Agent | None]], + list(await session.exec(statement)), + ) + @router.get("", response_model=DefaultLimitOffsetPage[ActivityEventRead]) async def list_activity( @@ -25,3 +114,67 @@ async def list_activity( statement = statement.where(ActivityEvent.agent_id == actor.agent.id) statement = statement.order_by(desc(col(ActivityEvent.created_at))) return await paginate(session, statement) + + +@router.get( + "/task-comments", + response_model=DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead], +) +async def list_task_comment_feed( + board_id: UUID | None = Query(default=None), + session: AsyncSession = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead]: + statement = ( + select(ActivityEvent, Task, Board, Agent) + .join(Task, col(ActivityEvent.task_id) == col(Task.id)) + .join(Board, col(Task.board_id) == col(Board.id)) + .outerjoin(Agent, col(ActivityEvent.agent_id) == col(Agent.id)) + .where(col(ActivityEvent.event_type) == "task.comment") + .where(func.length(func.trim(col(ActivityEvent.message))) > 0) + .order_by(desc(col(ActivityEvent.created_at))) + ) + if board_id is not None: + statement = statement.where(col(Task.board_id) == board_id) + + def _transform(items: Sequence[Any]) -> Sequence[Any]: + rows = cast(Sequence[tuple[ActivityEvent, Task, Board, Agent | None]], items) + return [_feed_item(event, task, board, agent) for event, task, board, agent in rows] + + return await paginate(session, statement, transformer=_transform) + + +@router.get("/task-comments/stream") +async def stream_task_comment_feed( + request: Request, + board_id: UUID | None = Query(default=None), + since: str | None = Query(default=None), + auth: AuthContext = Depends(require_admin_auth), +) -> EventSourceResponse: + since_dt = _parse_since(since) or utcnow() + seen_ids: set[UUID] = set() + seen_queue: deque[UUID] = deque() + + async def event_generator() -> AsyncIterator[dict[str, str]]: + last_seen = since_dt + while True: + if await request.is_disconnected(): + break + async with async_session_maker() as session: + rows = await _fetch_task_comment_events(session, last_seen, board_id=board_id) + for event, task, board, agent in rows: + event_id = event.id + if event_id in seen_ids: + continue + seen_ids.add(event_id) + seen_queue.append(event_id) + if len(seen_queue) > SSE_SEEN_MAX: + oldest = seen_queue.popleft() + seen_ids.discard(oldest) + if event.created_at > last_seen: + last_seen = event.created_at + payload = {"comment": _feed_item(event, task, board, agent).model_dump(mode="json")} + yield {"event": "comment", "data": json.dumps(payload)} + await asyncio.sleep(2) + + return EventSourceResponse(event_generator(), ping=15) diff --git a/backend/app/schemas/activity_events.py b/backend/app/schemas/activity_events.py index bd8b64a..6cade28 100644 --- a/backend/app/schemas/activity_events.py +++ b/backend/app/schemas/activity_events.py @@ -13,3 +13,16 @@ class ActivityEventRead(SQLModel): agent_id: UUID | None task_id: UUID | None created_at: datetime + + +class ActivityTaskCommentFeedItemRead(SQLModel): + id: UUID + created_at: datetime + message: str | None + agent_id: UUID | None + agent_name: str | None = None + agent_role: str | None = None + task_id: UUID + task_title: str + board_id: UUID + board_name: str diff --git a/frontend/src/api/generated/activity/activity.ts b/frontend/src/api/generated/activity/activity.ts index a568c0b..99491bf 100644 --- a/frontend/src/api/generated/activity/activity.ts +++ b/frontend/src/api/generated/activity/activity.ts @@ -20,7 +20,10 @@ import type { import type { HTTPValidationError, LimitOffsetPageTypeVarCustomizedActivityEventRead, + LimitOffsetPageTypeVarCustomizedActivityTaskCommentFeedItemRead, ListActivityApiV1ActivityGetParams, + ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams, + StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams, } from ".././model"; import { customFetch } from "../../mutator"; @@ -236,3 +239,533 @@ export function useListActivityApiV1ActivityGet< return { ...query, queryKey: queryOptions.queryKey }; } + +/** + * @summary List Task Comment Feed + */ +export type listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse200 = { + data: LimitOffsetPageTypeVarCustomizedActivityTaskCommentFeedItemRead; + status: 200; +}; + +export type listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type listTaskCommentFeedApiV1ActivityTaskCommentsGetResponseSuccess = + listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse200 & { + headers: Headers; + }; +export type listTaskCommentFeedApiV1ActivityTaskCommentsGetResponseError = + listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse422 & { + headers: Headers; + }; + +export type listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse = + | listTaskCommentFeedApiV1ActivityTaskCommentsGetResponseSuccess + | listTaskCommentFeedApiV1ActivityTaskCommentsGetResponseError; + +export const getListTaskCommentFeedApiV1ActivityTaskCommentsGetUrl = ( + params?: ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/activity/task-comments?${stringifiedParams}` + : `/api/v1/activity/task-comments`; +}; + +export const listTaskCommentFeedApiV1ActivityTaskCommentsGet = async ( + params?: ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams, + options?: RequestInit, +): Promise => { + return customFetch( + getListTaskCommentFeedApiV1ActivityTaskCommentsGetUrl(params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListTaskCommentFeedApiV1ActivityTaskCommentsGetQueryKey = ( + params?: ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams, +) => { + return [ + `/api/v1/activity/task-comments`, + ...(params ? [params] : []), + ] as const; +}; + +export const getListTaskCommentFeedApiV1ActivityTaskCommentsGetQueryOptions = < + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getListTaskCommentFeedApiV1ActivityTaskCommentsGetQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + listTaskCommentFeedApiV1ActivityTaskCommentsGet(params, { + signal, + ...requestOptions, + }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListTaskCommentFeedApiV1ActivityTaskCommentsGetQueryResult = + NonNullable< + Awaited> + >; +export type ListTaskCommentFeedApiV1ActivityTaskCommentsGetQueryError = + HTTPValidationError; + +export function useListTaskCommentFeedApiV1ActivityTaskCommentsGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: undefined | ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListTaskCommentFeedApiV1ActivityTaskCommentsGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType + >, + TError, + Awaited< + ReturnType + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListTaskCommentFeedApiV1ActivityTaskCommentsGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Task Comment Feed + */ + +export function useListTaskCommentFeedApiV1ActivityTaskCommentsGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getListTaskCommentFeedApiV1ActivityTaskCommentsGetQueryOptions( + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Stream Task Comment Feed + */ +export type streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetResponse200 = + { + data: unknown; + status: 200; + }; + +export type streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetResponseSuccess = + streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetResponse200 & { + headers: Headers; + }; +export type streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetResponseError = + streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetResponse422 & { + headers: Headers; + }; + +export type streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetResponse = + | streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetResponseSuccess + | streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetResponseError; + +export const getStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetUrl = ( + params?: StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams, +) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/activity/task-comments/stream?${stringifiedParams}` + : `/api/v1/activity/task-comments/stream`; +}; + +export const streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet = async ( + params?: StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams, + options?: RequestInit, +): Promise => { + return customFetch( + getStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetUrl(params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetQueryKey = + (params?: StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams) => { + return [ + `/api/v1/activity/task-comments/stream`, + ...(params ? [params] : []), + ] as const; + }; + +export const getStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetQueryOptions = + < + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, + >( + params?: StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + ) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetQueryKey( + params, + ); + + const queryFn: QueryFunction< + Awaited< + ReturnType< + typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet + > + > + > = ({ signal }) => + streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet(params, { + signal, + ...requestOptions, + }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited< + ReturnType< + typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet + > + >, + TError, + TData + > & { queryKey: DataTag }; + }; + +export type StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetQueryResult = + NonNullable< + Awaited< + ReturnType + > + >; +export type StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetQueryError = + HTTPValidationError; + +export function useStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params: + | undefined + | StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet + > + >, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited< + ReturnType< + typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet + > + >, + TError, + Awaited< + ReturnType< + typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet + > + >, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited< + ReturnType< + typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet + > + >, + TError, + Awaited< + ReturnType< + typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet + > + > + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Stream Task Comment Feed + */ + +export function useStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet< + TData = Awaited< + ReturnType + >, + TError = HTTPValidationError, +>( + params?: StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited< + ReturnType< + typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet + > + >, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = + getStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetQueryOptions( + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/frontend/src/api/generated/gateways/gateways.ts b/frontend/src/api/generated/gateways/gateways.ts index f5163b8..fcba77b 100644 --- a/frontend/src/api/generated/gateways/gateways.ts +++ b/frontend/src/api/generated/gateways/gateways.ts @@ -28,6 +28,7 @@ import type { GatewaySessionMessageRequest, GatewaySessionResponse, GatewaySessionsResponse, + GatewayTemplatesSyncResult, GatewayUpdate, GatewaysStatusApiV1GatewaysStatusGetParams, GatewaysStatusResponse, @@ -39,6 +40,7 @@ import type { ListGatewaysApiV1GatewaysGetParams, OkResponse, SendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams, + SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams, } from ".././model"; import { customFetch } from "../../mutator"; @@ -2229,3 +2231,192 @@ export const useUpdateGatewayApiV1GatewaysGatewayIdPatch = < queryClient, ); }; +/** + * @summary Sync Gateway Templates + */ +export type syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostResponse200 = + { + data: GatewayTemplatesSyncResult; + status: 200; + }; + +export type syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostResponse422 = + { + data: HTTPValidationError; + status: 422; + }; + +export type syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostResponseSuccess = + syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostResponse200 & { + headers: Headers; + }; +export type syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostResponseError = + syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostResponse422 & { + headers: Headers; + }; + +export type syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostResponse = + + | syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostResponseSuccess + | syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostResponseError; + +export const getSyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostUrl = + ( + gatewayId: string, + params?: SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams, + ) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append( + key, + value === null ? "null" : value.toString(), + ); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/gateways/${gatewayId}/templates/sync?${stringifiedParams}` + : `/api/v1/gateways/${gatewayId}/templates/sync`; + }; + +export const syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost = + async ( + gatewayId: string, + params?: SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams, + options?: RequestInit, + ): Promise => { + return customFetch( + getSyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostUrl( + gatewayId, + params, + ), + { + ...options, + method: "POST", + }, + ); + }; + +export const getSyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostMutationOptions = + (options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost + > + >, + TError, + { + gatewayId: string; + params?: SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams; + }, + TContext + >; + request?: SecondParameter; + }): UseMutationOptions< + Awaited< + ReturnType< + typeof syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost + > + >, + TError, + { + gatewayId: string; + params?: SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams; + }, + TContext + > => { + const mutationKey = [ + "syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost", + ]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited< + ReturnType< + typeof syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost + > + >, + { + gatewayId: string; + params?: SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams; + } + > = (props) => { + const { gatewayId, params } = props ?? {}; + + return syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost( + gatewayId, + params, + requestOptions, + ); + }; + + return { mutationFn, ...mutationOptions }; + }; + +export type SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostMutationResult = + NonNullable< + Awaited< + ReturnType< + typeof syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost + > + > + >; + +export type SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostMutationError = + HTTPValidationError; + +/** + * @summary Sync Gateway Templates + */ +export const useSyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited< + ReturnType< + typeof syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost + > + >, + TError, + { + gatewayId: string; + params?: SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams; + }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited< + ReturnType< + typeof syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost + > + >, + TError, + { + gatewayId: string; + params?: SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams; + }, + TContext +> => { + return useMutation( + getSyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostMutationOptions( + options, + ), + queryClient, + ); +}; diff --git a/frontend/src/api/generated/model/activityTaskCommentFeedItemRead.ts b/frontend/src/api/generated/model/activityTaskCommentFeedItemRead.ts new file mode 100644 index 0000000..3eeb407 --- /dev/null +++ b/frontend/src/api/generated/model/activityTaskCommentFeedItemRead.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface ActivityTaskCommentFeedItemRead { + agent_id: string | null; + agent_name?: string | null; + agent_role?: string | null; + board_id: string; + board_name: string; + created_at: string; + id: string; + message: string | null; + task_id: string; + task_title: string; +} diff --git a/frontend/src/api/generated/model/gatewayTemplatesSyncError.ts b/frontend/src/api/generated/model/gatewayTemplatesSyncError.ts new file mode 100644 index 0000000..62d81f4 --- /dev/null +++ b/frontend/src/api/generated/model/gatewayTemplatesSyncError.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface GatewayTemplatesSyncError { + agent_id?: string | null; + agent_name?: string | null; + board_id?: string | null; + message: string; +} diff --git a/frontend/src/api/generated/model/gatewayTemplatesSyncResult.ts b/frontend/src/api/generated/model/gatewayTemplatesSyncResult.ts new file mode 100644 index 0000000..3a592f7 --- /dev/null +++ b/frontend/src/api/generated/model/gatewayTemplatesSyncResult.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import type { GatewayTemplatesSyncError } from "./gatewayTemplatesSyncError"; + +export interface GatewayTemplatesSyncResult { + agents_skipped: number; + agents_updated: number; + errors?: GatewayTemplatesSyncError[]; + gateway_id: string; + include_main: boolean; + main_updated: boolean; + reset_sessions: boolean; +} diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index c2f9c8b..9889abc 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -6,6 +6,7 @@ */ export * from "./activityEventRead"; +export * from "./activityTaskCommentFeedItemRead"; export * from "./agentCreate"; export * from "./agentCreateHeartbeatConfig"; export * from "./agentCreateIdentityProfile"; @@ -86,6 +87,8 @@ export * from "./gatewaysStatusApiV1GatewaysStatusGetParams"; export * from "./gatewaysStatusResponse"; export * from "./gatewayStatusApiV1GatewayStatusGet200"; export * from "./gatewayStatusApiV1GatewayStatusGetParams"; +export * from "./gatewayTemplatesSyncError"; +export * from "./gatewayTemplatesSyncResult"; export * from "./gatewayUpdate"; export * from "./getGatewaySessionApiV1GatewaySessionsSessionIdGet200"; export * from "./getGatewaySessionApiV1GatewaySessionsSessionIdGetParams"; @@ -97,6 +100,7 @@ export * from "./healthHealthGet200"; export * from "./healthzHealthzGet200"; export * from "./hTTPValidationError"; export * from "./limitOffsetPageTypeVarCustomizedActivityEventRead"; +export * from "./limitOffsetPageTypeVarCustomizedActivityTaskCommentFeedItemRead"; export * from "./limitOffsetPageTypeVarCustomizedAgentRead"; export * from "./limitOffsetPageTypeVarCustomizedApprovalRead"; export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead"; @@ -117,6 +121,7 @@ export * from "./listGatewaysApiV1GatewaysGetParams"; export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams"; export * from "./listSessionsApiV1GatewaySessionsGet200"; export * from "./listSessionsApiV1GatewaySessionsGetParams"; +export * from "./listTaskCommentFeedApiV1ActivityTaskCommentsGetParams"; export * from "./listTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetParams"; export * from "./listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGetParams"; export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams"; @@ -130,7 +135,9 @@ export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostParam export * from "./streamAgentsApiV1AgentsStreamGetParams"; export * from "./streamApprovalsApiV1BoardsBoardIdApprovalsStreamGetParams"; export * from "./streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGetParams"; +export * from "./streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams"; export * from "./streamTasksApiV1BoardsBoardIdTasksStreamGetParams"; +export * from "./syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams"; export * from "./taskCardRead"; export * from "./taskCardReadStatus"; export * from "./taskCommentCreate"; diff --git a/frontend/src/api/generated/model/limitOffsetPageTypeVarCustomizedActivityTaskCommentFeedItemRead.ts b/frontend/src/api/generated/model/limitOffsetPageTypeVarCustomizedActivityTaskCommentFeedItemRead.ts new file mode 100644 index 0000000..94b874c --- /dev/null +++ b/frontend/src/api/generated/model/limitOffsetPageTypeVarCustomizedActivityTaskCommentFeedItemRead.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import type { ActivityTaskCommentFeedItemRead } from "./activityTaskCommentFeedItemRead"; + +export interface LimitOffsetPageTypeVarCustomizedActivityTaskCommentFeedItemRead { + items: ActivityTaskCommentFeedItemRead[]; + /** @minimum 1 */ + limit: number; + /** @minimum 0 */ + offset: number; + /** @minimum 0 */ + total: number; +} diff --git a/frontend/src/api/generated/model/listTaskCommentFeedApiV1ActivityTaskCommentsGetParams.ts b/frontend/src/api/generated/model/listTaskCommentFeedApiV1ActivityTaskCommentsGetParams.ts new file mode 100644 index 0000000..c73c84a --- /dev/null +++ b/frontend/src/api/generated/model/listTaskCommentFeedApiV1ActivityTaskCommentsGetParams.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export type ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams = { + board_id?: string | null; + /** + * @minimum 1 + * @maximum 200 + */ + limit?: number; + /** + * @minimum 0 + */ + offset?: number; +}; diff --git a/frontend/src/api/generated/model/streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams.ts b/frontend/src/api/generated/model/streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams.ts new file mode 100644 index 0000000..36de3a2 --- /dev/null +++ b/frontend/src/api/generated/model/streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export type StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams = { + board_id?: string | null; + since?: string | null; +}; diff --git a/frontend/src/api/generated/model/syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams.ts b/frontend/src/api/generated/model/syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams.ts new file mode 100644 index 0000000..0a1f6d7 --- /dev/null +++ b/frontend/src/api/generated/model/syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export type SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams = + { + include_main?: boolean; + reset_sessions?: boolean; + rotate_tokens?: boolean; + force_bootstrap?: boolean; + board_id?: string | null; + }; diff --git a/frontend/src/app/activity/page.tsx b/frontend/src/app/activity/page.tsx new file mode 100644 index 0000000..88167d9 --- /dev/null +++ b/frontend/src/app/activity/page.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; + +import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; +import { ArrowUpRight, Activity as ActivityIcon } from "lucide-react"; + +import { ApiError } from "@/api/mutator"; +import { + type listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse, + streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet, + useListTaskCommentFeedApiV1ActivityTaskCommentsGet, +} from "@/api/generated/activity/activity"; +import type { ActivityTaskCommentFeedItemRead } from "@/api/generated/model"; +import { Markdown } from "@/components/atoms/Markdown"; +import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; +import { DashboardShell } from "@/components/templates/DashboardShell"; +import { Button } from "@/components/ui/button"; +import { createExponentialBackoff } from "@/lib/backoff"; +import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; +import { cn } from "@/lib/utils"; + +const SSE_RECONNECT_BACKOFF = { + baseMs: 1_000, + factor: 2, + jitter: 0.2, + maxMs: 5 * 60_000, +} as const; + +const formatShortTimestamp = (value: string) => { + const date = parseApiDatetime(value); + if (!date) return "—"; + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +const latestTimestamp = (items: ActivityTaskCommentFeedItemRead[]) => { + let latest = 0; + for (const item of items) { + const time = apiDatetimeToMs(item.created_at) ?? 0; + latest = Math.max(latest, time); + } + return latest ? new Date(latest).toISOString() : null; +}; + +const FeedCard = memo(function FeedCard({ + item, +}: { + item: ActivityTaskCommentFeedItemRead; +}) { + const message = (item.message ?? "").trim(); + const authorName = item.agent_name?.trim() || "Admin"; + const authorRole = item.agent_role?.trim() || null; + const authorAvatar = (authorName[0] ?? "A").toUpperCase(); + + const taskHref = `/boards/${item.board_id}?taskId=${item.task_id}`; + const boardHref = `/boards/${item.board_id}`; + + return ( +
+
+
+ {authorAvatar} +
+
+
+
+ + {item.task_title} + +
+ + {item.board_name} + + · + {authorName} + {authorRole ? ( + <> + · + {authorRole} + + ) : null} + · + + {formatShortTimestamp(item.created_at)} + +
+
+ + View task + + +
+
+
+ {message ? ( +
+ +
+ ) : ( +

+ )} +
+ ); +}); + +FeedCard.displayName = "FeedCard"; + +export default function ActivityPage() { + const { isSignedIn } = useAuth(); + + const feedQuery = useListTaskCommentFeedApiV1ActivityTaskCommentsGet< + listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse, + ApiError + >( + { limit: 200 }, + { + query: { + enabled: Boolean(isSignedIn), + refetchOnMount: "always", + refetchOnWindowFocus: false, + retry: false, + }, + }, + ); + + const [feedItems, setFeedItems] = useState( + [], + ); + const feedItemsRef = useRef([]); + const seenIdsRef = useRef>(new Set()); + const initializedRef = useRef(false); + + useEffect(() => { + feedItemsRef.current = feedItems; + }, [feedItems]); + + useEffect(() => { + if (initializedRef.current) return; + if (feedQuery.data?.status !== 200) return; + const items = feedQuery.data.data.items ?? []; + initializedRef.current = true; + setFeedItems((prev) => { + const map = new Map(); + [...prev, ...items].forEach((item) => map.set(item.id, item)); + const merged = [...map.values()]; + merged.sort((a, b) => { + const aTime = apiDatetimeToMs(a.created_at) ?? 0; + const bTime = apiDatetimeToMs(b.created_at) ?? 0; + return bTime - aTime; + }); + const next = merged.slice(0, 200); + seenIdsRef.current = new Set(next.map((item) => item.id)); + return next; + }); + }, [feedQuery.data]); + + const pushFeedItem = useCallback((item: ActivityTaskCommentFeedItemRead) => { + setFeedItems((prev) => { + if (seenIdsRef.current.has(item.id)) return prev; + seenIdsRef.current.add(item.id); + const next = [item, ...prev]; + return next.slice(0, 200); + }); + }, []); + + useEffect(() => { + if (!isSignedIn) return; + let isCancelled = false; + const abortController = new AbortController(); + const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); + let reconnectTimeout: number | undefined; + + const connect = async () => { + try { + const since = latestTimestamp(feedItemsRef.current); + const streamResult = + await streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet( + since ? { since } : undefined, + { + headers: { Accept: "text/event-stream" }, + signal: abortController.signal, + }, + ); + if (streamResult.status !== 200) { + throw new Error("Unable to connect task comment feed stream."); + } + const response = streamResult.data as Response; + if (!(response instanceof Response) || !response.body) { + throw new Error("Unable to connect task comment 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?: ActivityTaskCommentFeedItemRead; + }; + if (payload.comment) { + pushFeedItem(payload.comment); + } + } 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); + } + }; + }, [isSignedIn, pushFeedItem]); + + const orderedFeed = useMemo(() => { + return [...feedItems].sort((a, b) => { + const aTime = apiDatetimeToMs(a.created_at) ?? 0; + const bTime = apiDatetimeToMs(b.created_at) ?? 0; + return bTime - aTime; + }); + }, [feedItems]); + + return ( + + +
+
+

Sign in to view the feed.

+ + + +
+
+
+ + +
+
+
+
+
+
+ +

+ Live feed +

+
+

+ Realtime task comments across all boards. +

+
+
+
+
+ +
+ {feedQuery.isLoading && feedItems.length === 0 ? ( +

Loading feed…

+ ) : feedQuery.error ? ( +
+ {feedQuery.error.message || "Unable to load feed."} +
+ ) : orderedFeed.length === 0 ? ( +
+

+ Waiting for new comments… +

+

+ When agents post updates, they will show up here. +

+
+ ) : ( +
+ {orderedFeed.map((item) => ( + + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 11a24cc..b7736a6 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; import { @@ -12,10 +12,8 @@ import { Settings, X, } from "lucide-react"; -import ReactMarkdown, { type Components } from "react-markdown"; -import remarkBreaks from "remark-breaks"; -import remarkGfm from "remark-gfm"; +import { Markdown } from "@/components/atoms/Markdown"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { TaskBoard } from "@/components/organisms/TaskBoard"; import { DashboardShell } from "@/components/templates/DashboardShell"; @@ -144,118 +142,6 @@ const SSE_RECONNECT_BACKOFF = { maxMs: 5 * 60_000, } as const; -const MARKDOWN_TABLE_COMPONENTS: Components = { - table: ({ node: _node, className, ...props }) => ( -
- - - ), - thead: ({ node: _node, className, ...props }) => ( - - ), - tbody: ({ node: _node, className, ...props }) => ( - - ), - tr: ({ node: _node, className, ...props }) => ( - - ), - th: ({ node: _node, className, ...props }) => ( -
- ), - td: ({ node: _node, className, ...props }) => ( - - ), -}; - -const MARKDOWN_COMPONENTS_BASIC: Components = { - ...MARKDOWN_TABLE_COMPONENTS, - p: ({ node: _node, className, ...props }) => ( -

- ), - ul: ({ node: _node, className, ...props }) => ( -

    - ), - ol: ({ node: _node, className, ...props }) => ( -
      - ), - li: ({ node: _node, className, ...props }) => ( -
    1. - ), - strong: ({ node: _node, className, ...props }) => ( - - ), -}; - -const MARKDOWN_COMPONENTS_DESCRIPTION: Components = { - ...MARKDOWN_COMPONENTS_BASIC, - p: ({ node: _node, className, ...props }) => ( -

      - ), - h1: ({ node: _node, className, ...props }) => ( -

      - ), - h2: ({ node: _node, className, ...props }) => ( -

      - ), - h3: ({ node: _node, className, ...props }) => ( -

      - ), - code: ({ node: _node, className, ...props }) => ( - - ), - pre: ({ node: _node, className, ...props }) => ( -
      -  ),
      -};
      -
      -const MARKDOWN_REMARK_PLUGINS_BASIC = [remarkGfm];
      -const MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [remarkGfm, remarkBreaks];
      -
      -type MarkdownVariant = "basic" | "comment" | "description";
      -
      -const Markdown = memo(function Markdown({
      -  content,
      -  variant,
      -}: {
      -  content: string;
      -  variant: MarkdownVariant;
      -}) {
      -  const trimmed = content.trim();
      -  const remarkPlugins =
      -    variant === "comment"
      -      ? MARKDOWN_REMARK_PLUGINS_WITH_BREAKS
      -      : MARKDOWN_REMARK_PLUGINS_BASIC;
      -  const components =
      -    variant === "description"
      -      ? MARKDOWN_COMPONENTS_DESCRIPTION
      -      : MARKDOWN_COMPONENTS_BASIC;
      -  return (
      -    
      -      {trimmed}
      -    
      -  );
      -});
      -
      -Markdown.displayName = "Markdown";
      -
       const formatShortTimestamp = (value: string) => {
         const date = parseApiDatetime(value);
         if (!date) return "—";
      @@ -405,9 +291,11 @@ LiveFeedCard.displayName = "LiveFeedCard";
       export default function BoardDetailPage() {
         const router = useRouter();
         const params = useParams();
      +  const searchParams = useSearchParams();
         const boardIdParam = params?.boardId;
         const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
         const { isSignedIn } = useAuth();
      +  const taskIdFromUrl = searchParams.get("taskId");
       
         const [board, setBoard] = useState(null);
         const [tasks, setTasks] = useState([]);
      @@ -416,6 +304,7 @@ export default function BoardDetailPage() {
         const [error, setError] = useState(null);
         const [selectedTask, setSelectedTask] = useState(null);
         const selectedTaskIdRef = useRef(null);
      +  const openedTaskIdFromUrlRef = useRef(null);
         const [comments, setComments] = useState([]);
         const [liveFeed, setLiveFeed] = useState([]);
         const [isCommentsLoading, setIsCommentsLoading] = useState(false);
      @@ -1408,6 +1297,15 @@ export default function BoardDetailPage() {
           [loadComments],
         );
       
      +  useEffect(() => {
      +    if (!taskIdFromUrl) return;
      +    if (openedTaskIdFromUrlRef.current === taskIdFromUrl) return;
      +    const exists = tasks.some((task) => task.id === taskIdFromUrl);
      +    if (!exists) return;
      +    openedTaskIdFromUrlRef.current = taskIdFromUrl;
      +    openComments({ id: taskIdFromUrl });
      +  }, [openComments, taskIdFromUrl, tasks]);
      +
         const closeComments = () => {
           setIsDetailOpen(false);
           selectedTaskIdRef.current = null;
      diff --git a/frontend/src/components/atoms/Markdown.tsx b/frontend/src/components/atoms/Markdown.tsx
      new file mode 100644
      index 0000000..acf8589
      --- /dev/null
      +++ b/frontend/src/components/atoms/Markdown.tsx
      @@ -0,0 +1,122 @@
      +"use client";
      +
      +import { memo } from "react";
      +
      +import ReactMarkdown, { type Components } from "react-markdown";
      +import remarkBreaks from "remark-breaks";
      +import remarkGfm from "remark-gfm";
      +
      +import { cn } from "@/lib/utils";
      +
      +const MARKDOWN_TABLE_COMPONENTS: Components = {
      +  table: ({ node: _node, className, ...props }) => (
      +    
      + + + ), + thead: ({ node: _node, className, ...props }) => ( + + ), + tbody: ({ node: _node, className, ...props }) => ( + + ), + tr: ({ node: _node, className, ...props }) => ( + + ), + th: ({ node: _node, className, ...props }) => ( +
      + ), + td: ({ node: _node, className, ...props }) => ( + + ), +}; + +const MARKDOWN_COMPONENTS_BASIC: Components = { + ...MARKDOWN_TABLE_COMPONENTS, + p: ({ node: _node, className, ...props }) => ( +

      + ), + ul: ({ node: _node, className, ...props }) => ( +

        + ), + ol: ({ node: _node, className, ...props }) => ( +
          + ), + li: ({ node: _node, className, ...props }) => ( +
        1. + ), + strong: ({ node: _node, className, ...props }) => ( + + ), +}; + +const MARKDOWN_COMPONENTS_DESCRIPTION: Components = { + ...MARKDOWN_COMPONENTS_BASIC, + p: ({ node: _node, className, ...props }) => ( +

          + ), + h1: ({ node: _node, className, ...props }) => ( +

          + ), + h2: ({ node: _node, className, ...props }) => ( +

          + ), + h3: ({ node: _node, className, ...props }) => ( +

          + ), + code: ({ node: _node, className, ...props }) => ( + + ), + pre: ({ node: _node, className, ...props }) => ( +
          +  ),
          +};
          +
          +const MARKDOWN_REMARK_PLUGINS_BASIC = [remarkGfm];
          +const MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [remarkGfm, remarkBreaks];
          +
          +export type MarkdownVariant = "basic" | "comment" | "description";
          +
          +export const Markdown = memo(function Markdown({
          +  content,
          +  variant,
          +}: {
          +  content: string;
          +  variant: MarkdownVariant;
          +}) {
          +  const trimmed = content.trim();
          +  const remarkPlugins =
          +    variant === "comment"
          +      ? MARKDOWN_REMARK_PLUGINS_WITH_BREAKS
          +      : MARKDOWN_REMARK_PLUGINS_BASIC;
          +  const components =
          +    variant === "description"
          +      ? MARKDOWN_COMPONENTS_DESCRIPTION
          +      : MARKDOWN_COMPONENTS_BASIC;
          +  return (
          +    
          +      {trimmed}
          +    
          +  );
          +});
          +
          +Markdown.displayName = "Markdown";
          +
          diff --git a/frontend/src/components/organisms/DashboardSidebar.tsx b/frontend/src/components/organisms/DashboardSidebar.tsx
          index 9e75c51..46e2412 100644
          --- a/frontend/src/components/organisms/DashboardSidebar.tsx
          +++ b/frontend/src/components/organisms/DashboardSidebar.tsx
          @@ -2,7 +2,7 @@
           
           import Link from "next/link";
           import { usePathname } from "next/navigation";
          -import { BarChart3, Bot, LayoutGrid, Network } from "lucide-react";
          +import { Activity, BarChart3, Bot, LayoutGrid, Network } from "lucide-react";
           
           import { ApiError } from "@/api/mutator";
           import {
          @@ -81,6 +81,18 @@ export function DashboardSidebar() {
                       
                       Boards
                     
          +          
          +            
          +            Live feed
          +