Add global live feed for task comments

This commit is contained in:
Abhimanyu Saharan
2026-02-07 05:26:15 +05:30
parent 844b521d00
commit b2109da88b
16 changed files with 1518 additions and 122 deletions

View File

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

View File

@@ -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

View File

@@ -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<listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse> => {
return customFetch<listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse>(
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<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError = HTTPValidationError,
>(
params?: ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getListTaskCommentFeedApiV1ActivityTaskCommentsGetQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>>
> = ({ signal }) =>
listTaskCommentFeedApiV1ActivityTaskCommentsGet(params, {
signal,
...requestOptions,
});
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ListTaskCommentFeedApiV1ActivityTaskCommentsGetQueryResult =
NonNullable<
Awaited<ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>>
>;
export type ListTaskCommentFeedApiV1ActivityTaskCommentsGetQueryError =
HTTPValidationError;
export function useListTaskCommentFeedApiV1ActivityTaskCommentsGet<
TData = Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError = HTTPValidationError,
>(
params: undefined | ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError,
Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListTaskCommentFeedApiV1ActivityTaskCommentsGet<
TData = Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError = HTTPValidationError,
>(
params?: ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError,
Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useListTaskCommentFeedApiV1ActivityTaskCommentsGet<
TData = Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError = HTTPValidationError,
>(
params?: ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary List Task Comment Feed
*/
export function useListTaskCommentFeedApiV1ActivityTaskCommentsGet<
TData = Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError = HTTPValidationError,
>(
params?: ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<typeof listTaskCommentFeedApiV1ActivityTaskCommentsGet>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions =
getListTaskCommentFeedApiV1ActivityTaskCommentsGetQueryOptions(
params,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
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<streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetResponse> => {
return customFetch<streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetResponse>(
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<typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet>
>,
TError = HTTPValidationError,
>(
params?: StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<
typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet
>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
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<QueryKey, TData, TError> };
};
export type StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetQueryResult =
NonNullable<
Awaited<
ReturnType<typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet>
>
>;
export type StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetQueryError =
HTTPValidationError;
export function useStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet<
TData = Awaited<
ReturnType<typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet>
>,
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<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet<
TData = Awaited<
ReturnType<typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet>
>,
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<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet<
TData = Awaited<
ReturnType<typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet>
>,
TError = HTTPValidationError,
>(
params?: StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<
typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet
>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Stream Task Comment Feed
*/
export function useStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet<
TData = Awaited<
ReturnType<typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet>
>,
TError = HTTPValidationError,
>(
params?: StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<
ReturnType<
typeof streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet
>
>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions =
getStreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetQueryOptions(
params,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}

View File

@@ -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<syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostResponse> => {
return customFetch<syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostResponse>(
getSyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostUrl(
gatewayId,
params,
),
{
...options,
method: "POST",
},
);
};
export const getSyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostMutationOptions =
<TError = HTTPValidationError, TContext = unknown>(options?: {
mutation?: UseMutationOptions<
Awaited<
ReturnType<
typeof syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost
>
>,
TError,
{
gatewayId: string;
params?: SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams;
},
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): 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<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<
ReturnType<
typeof syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPost
>
>,
TError,
{
gatewayId: string;
params?: SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams;
},
TContext
> => {
return useMutation(
getSyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostMutationOptions(
options,
),
queryClient,
);
};

View File

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

View File

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

View File

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

View File

@@ -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";

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="rounded-xl border border-slate-200 bg-white p-4 transition hover:border-slate-300">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-700">
{authorAvatar}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={taskHref}
className={cn(
"block text-sm font-semibold leading-snug text-slate-900 transition hover:text-slate-950 hover:underline",
)}
title={item.task_title}
style={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{item.task_title}
</Link>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
<Link
href={boardHref}
className="font-semibold text-slate-700 hover:text-slate-900 hover:underline"
>
{item.board_name}
</Link>
<span className="text-slate-300">·</span>
<span className="font-medium text-slate-700">{authorName}</span>
{authorRole ? (
<>
<span className="text-slate-300">·</span>
<span className="text-slate-500">{authorRole}</span>
</>
) : null}
<span className="text-slate-300">·</span>
<span className="text-slate-400">
{formatShortTimestamp(item.created_at)}
</span>
</div>
</div>
<Link
href={taskHref}
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" />
</Link>
</div>
</div>
</div>
{message ? (
<div className="mt-3 select-text cursor-text text-sm leading-relaxed text-slate-900 break-words">
<Markdown content={message} variant="basic" />
</div>
) : (
<p className="mt-3 text-sm text-slate-500"></p>
)}
</div>
);
});
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<ActivityTaskCommentFeedItemRead[]>(
[],
);
const feedItemsRef = useRef<ActivityTaskCommentFeedItemRead[]>([]);
const seenIdsRef = useRef<Set<string>>(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<string, ActivityTaskCommentFeedItemRead>();
[...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 (
<DashboardShell>
<SignedOut>
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
<p className="text-sm text-slate-600">Sign in to view the feed.</p>
<SignInButton
mode="modal"
forceRedirectUrl="/activity"
signUpForceRedirectUrl="/activity"
>
<Button className="mt-4">Sign in</Button>
</SignInButton>
</div>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main className="flex-1 overflow-y-auto bg-slate-50">
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white">
<div className="px-8 py-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<ActivityIcon className="h-5 w-5 text-slate-600" />
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
Live feed
</h1>
</div>
<p className="mt-1 text-sm text-slate-500">
Realtime task comments across all boards.
</p>
</div>
</div>
</div>
</div>
<div className="p-8">
{feedQuery.isLoading && feedItems.length === 0 ? (
<p className="text-sm text-slate-500">Loading feed</p>
) : feedQuery.error ? (
<div className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-sm">
{feedQuery.error.message || "Unable to load feed."}
</div>
) : orderedFeed.length === 0 ? (
<div className="rounded-xl border border-slate-200 bg-white p-10 text-center shadow-sm">
<p className="text-sm font-medium text-slate-900">
Waiting for new comments
</p>
<p className="mt-1 text-sm text-slate-500">
When agents post updates, they will show up here.
</p>
</div>
) : (
<div className="space-y-4">
{orderedFeed.map((item) => (
<FeedCard key={item.id} item={item} />
))}
</div>
)}
</div>
</main>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -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 }) => (
<div className="my-3 overflow-x-auto">
<table className={cn("w-full border-collapse", className)} {...props} />
</div>
),
thead: ({ node: _node, className, ...props }) => (
<thead className={cn("bg-slate-50", className)} {...props} />
),
tbody: ({ node: _node, className, ...props }) => (
<tbody className={cn("divide-y divide-slate-100", className)} {...props} />
),
tr: ({ node: _node, className, ...props }) => (
<tr className={cn("align-top", className)} {...props} />
),
th: ({ node: _node, className, ...props }) => (
<th
className={cn(
"border border-slate-200 px-3 py-2 text-left text-xs font-semibold",
className,
)}
{...props}
/>
),
td: ({ node: _node, className, ...props }) => (
<td
className={cn("border border-slate-200 px-3 py-2 align-top", className)}
{...props}
/>
),
};
const MARKDOWN_COMPONENTS_BASIC: Components = {
...MARKDOWN_TABLE_COMPONENTS,
p: ({ node: _node, className, ...props }) => (
<p className={cn("mb-2 last:mb-0", className)} {...props} />
),
ul: ({ node: _node, className, ...props }) => (
<ul className={cn("mb-2 list-disc pl-5", className)} {...props} />
),
ol: ({ node: _node, className, ...props }) => (
<ol className={cn("mb-2 list-decimal pl-5", className)} {...props} />
),
li: ({ node: _node, className, ...props }) => (
<li className={cn("mb-1", className)} {...props} />
),
strong: ({ node: _node, className, ...props }) => (
<strong className={cn("font-semibold", className)} {...props} />
),
};
const MARKDOWN_COMPONENTS_DESCRIPTION: Components = {
...MARKDOWN_COMPONENTS_BASIC,
p: ({ node: _node, className, ...props }) => (
<p className={cn("mb-3 last:mb-0", className)} {...props} />
),
h1: ({ node: _node, className, ...props }) => (
<h1 className={cn("mb-2 text-base font-semibold", className)} {...props} />
),
h2: ({ node: _node, className, ...props }) => (
<h2 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
),
h3: ({ node: _node, className, ...props }) => (
<h3 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
),
code: ({ node: _node, className, ...props }) => (
<code
className={cn("rounded bg-slate-100 px-1 py-0.5 text-xs", className)}
{...props}
/>
),
pre: ({ node: _node, className, ...props }) => (
<pre
className={cn(
"overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100",
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 (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{trimmed}
</ReactMarkdown>
);
});
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<Board | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
@@ -416,6 +304,7 @@ export default function BoardDetailPage() {
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const selectedTaskIdRef = useRef<string | null>(null);
const openedTaskIdFromUrlRef = useRef<string | null>(null);
const [comments, setComments] = useState<TaskComment[]>([]);
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
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;

View File

@@ -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 }) => (
<div className="my-3 overflow-x-auto">
<table className={cn("w-full border-collapse", className)} {...props} />
</div>
),
thead: ({ node: _node, className, ...props }) => (
<thead className={cn("bg-slate-50", className)} {...props} />
),
tbody: ({ node: _node, className, ...props }) => (
<tbody className={cn("divide-y divide-slate-100", className)} {...props} />
),
tr: ({ node: _node, className, ...props }) => (
<tr className={cn("align-top", className)} {...props} />
),
th: ({ node: _node, className, ...props }) => (
<th
className={cn(
"border border-slate-200 px-3 py-2 text-left text-xs font-semibold",
className,
)}
{...props}
/>
),
td: ({ node: _node, className, ...props }) => (
<td
className={cn("border border-slate-200 px-3 py-2 align-top", className)}
{...props}
/>
),
};
const MARKDOWN_COMPONENTS_BASIC: Components = {
...MARKDOWN_TABLE_COMPONENTS,
p: ({ node: _node, className, ...props }) => (
<p className={cn("mb-2 last:mb-0", className)} {...props} />
),
ul: ({ node: _node, className, ...props }) => (
<ul className={cn("mb-2 list-disc pl-5", className)} {...props} />
),
ol: ({ node: _node, className, ...props }) => (
<ol className={cn("mb-2 list-decimal pl-5", className)} {...props} />
),
li: ({ node: _node, className, ...props }) => (
<li className={cn("mb-1", className)} {...props} />
),
strong: ({ node: _node, className, ...props }) => (
<strong className={cn("font-semibold", className)} {...props} />
),
};
const MARKDOWN_COMPONENTS_DESCRIPTION: Components = {
...MARKDOWN_COMPONENTS_BASIC,
p: ({ node: _node, className, ...props }) => (
<p className={cn("mb-3 last:mb-0", className)} {...props} />
),
h1: ({ node: _node, className, ...props }) => (
<h1 className={cn("mb-2 text-base font-semibold", className)} {...props} />
),
h2: ({ node: _node, className, ...props }) => (
<h2 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
),
h3: ({ node: _node, className, ...props }) => (
<h3 className={cn("mb-2 text-sm font-semibold", className)} {...props} />
),
code: ({ node: _node, className, ...props }) => (
<code
className={cn("rounded bg-slate-100 px-1 py-0.5 text-xs", className)}
{...props}
/>
),
pre: ({ node: _node, className, ...props }) => (
<pre
className={cn(
"overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100",
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 (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{trimmed}
</ReactMarkdown>
);
});
Markdown.displayName = "Markdown";

View File

@@ -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() {
<LayoutGrid className="h-4 w-4" />
Boards
</Link>
<Link
href="/activity"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
pathname.startsWith("/activity")
? "bg-blue-100 text-blue-800 font-medium"
: "hover:bg-slate-100"
)}
>
<Activity className="h-4 w-4" />
Live feed
</Link>
<Link
href="/agents"
className={cn(