Add global live feed for task comments
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
356
frontend/src/app/activity/page.tsx
Normal file
356
frontend/src/app/activity/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
122
frontend/src/components/atoms/Markdown.tsx
Normal file
122
frontend/src/components/atoms/Markdown.tsx
Normal 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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user