Add global live feed for task comments
This commit is contained in:
@@ -1,19 +1,108 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
import asyncio
|
||||||
from sqlalchemy import desc
|
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 import col, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
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.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.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
|
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||||
|
|
||||||
router = APIRouter(prefix="/activity", tags=["activity"])
|
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])
|
@router.get("", response_model=DefaultLimitOffsetPage[ActivityEventRead])
|
||||||
async def list_activity(
|
async def list_activity(
|
||||||
@@ -25,3 +114,67 @@ async def list_activity(
|
|||||||
statement = statement.where(ActivityEvent.agent_id == actor.agent.id)
|
statement = statement.where(ActivityEvent.agent_id == actor.agent.id)
|
||||||
statement = statement.order_by(desc(col(ActivityEvent.created_at)))
|
statement = statement.order_by(desc(col(ActivityEvent.created_at)))
|
||||||
return await paginate(session, statement)
|
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
|
agent_id: UUID | None
|
||||||
task_id: UUID | None
|
task_id: UUID | None
|
||||||
created_at: datetime
|
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 {
|
import type {
|
||||||
HTTPValidationError,
|
HTTPValidationError,
|
||||||
LimitOffsetPageTypeVarCustomizedActivityEventRead,
|
LimitOffsetPageTypeVarCustomizedActivityEventRead,
|
||||||
|
LimitOffsetPageTypeVarCustomizedActivityTaskCommentFeedItemRead,
|
||||||
ListActivityApiV1ActivityGetParams,
|
ListActivityApiV1ActivityGetParams,
|
||||||
|
ListTaskCommentFeedApiV1ActivityTaskCommentsGetParams,
|
||||||
|
StreamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams,
|
||||||
} from ".././model";
|
} from ".././model";
|
||||||
|
|
||||||
import { customFetch } from "../../mutator";
|
import { customFetch } from "../../mutator";
|
||||||
@@ -236,3 +239,533 @@ export function useListActivityApiV1ActivityGet<
|
|||||||
|
|
||||||
return { ...query, queryKey: queryOptions.queryKey };
|
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,
|
GatewaySessionMessageRequest,
|
||||||
GatewaySessionResponse,
|
GatewaySessionResponse,
|
||||||
GatewaySessionsResponse,
|
GatewaySessionsResponse,
|
||||||
|
GatewayTemplatesSyncResult,
|
||||||
GatewayUpdate,
|
GatewayUpdate,
|
||||||
GatewaysStatusApiV1GatewaysStatusGetParams,
|
GatewaysStatusApiV1GatewaysStatusGetParams,
|
||||||
GatewaysStatusResponse,
|
GatewaysStatusResponse,
|
||||||
@@ -39,6 +40,7 @@ import type {
|
|||||||
ListGatewaysApiV1GatewaysGetParams,
|
ListGatewaysApiV1GatewaysGetParams,
|
||||||
OkResponse,
|
OkResponse,
|
||||||
SendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams,
|
SendGatewaySessionMessageApiV1GatewaysSessionsSessionIdMessagePostParams,
|
||||||
|
SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams,
|
||||||
} from ".././model";
|
} from ".././model";
|
||||||
|
|
||||||
import { customFetch } from "../../mutator";
|
import { customFetch } from "../../mutator";
|
||||||
@@ -2229,3 +2231,192 @@ export const useUpdateGatewayApiV1GatewaysGatewayIdPatch = <
|
|||||||
queryClient,
|
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 "./activityEventRead";
|
||||||
|
export * from "./activityTaskCommentFeedItemRead";
|
||||||
export * from "./agentCreate";
|
export * from "./agentCreate";
|
||||||
export * from "./agentCreateHeartbeatConfig";
|
export * from "./agentCreateHeartbeatConfig";
|
||||||
export * from "./agentCreateIdentityProfile";
|
export * from "./agentCreateIdentityProfile";
|
||||||
@@ -86,6 +87,8 @@ export * from "./gatewaysStatusApiV1GatewaysStatusGetParams";
|
|||||||
export * from "./gatewaysStatusResponse";
|
export * from "./gatewaysStatusResponse";
|
||||||
export * from "./gatewayStatusApiV1GatewayStatusGet200";
|
export * from "./gatewayStatusApiV1GatewayStatusGet200";
|
||||||
export * from "./gatewayStatusApiV1GatewayStatusGetParams";
|
export * from "./gatewayStatusApiV1GatewayStatusGetParams";
|
||||||
|
export * from "./gatewayTemplatesSyncError";
|
||||||
|
export * from "./gatewayTemplatesSyncResult";
|
||||||
export * from "./gatewayUpdate";
|
export * from "./gatewayUpdate";
|
||||||
export * from "./getGatewaySessionApiV1GatewaySessionsSessionIdGet200";
|
export * from "./getGatewaySessionApiV1GatewaySessionsSessionIdGet200";
|
||||||
export * from "./getGatewaySessionApiV1GatewaySessionsSessionIdGetParams";
|
export * from "./getGatewaySessionApiV1GatewaySessionsSessionIdGetParams";
|
||||||
@@ -97,6 +100,7 @@ export * from "./healthHealthGet200";
|
|||||||
export * from "./healthzHealthzGet200";
|
export * from "./healthzHealthzGet200";
|
||||||
export * from "./hTTPValidationError";
|
export * from "./hTTPValidationError";
|
||||||
export * from "./limitOffsetPageTypeVarCustomizedActivityEventRead";
|
export * from "./limitOffsetPageTypeVarCustomizedActivityEventRead";
|
||||||
|
export * from "./limitOffsetPageTypeVarCustomizedActivityTaskCommentFeedItemRead";
|
||||||
export * from "./limitOffsetPageTypeVarCustomizedAgentRead";
|
export * from "./limitOffsetPageTypeVarCustomizedAgentRead";
|
||||||
export * from "./limitOffsetPageTypeVarCustomizedApprovalRead";
|
export * from "./limitOffsetPageTypeVarCustomizedApprovalRead";
|
||||||
export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead";
|
export * from "./limitOffsetPageTypeVarCustomizedBoardMemoryRead";
|
||||||
@@ -117,6 +121,7 @@ export * from "./listGatewaysApiV1GatewaysGetParams";
|
|||||||
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
|
export * from "./listGatewaySessionsApiV1GatewaysSessionsGetParams";
|
||||||
export * from "./listSessionsApiV1GatewaySessionsGet200";
|
export * from "./listSessionsApiV1GatewaySessionsGet200";
|
||||||
export * from "./listSessionsApiV1GatewaySessionsGetParams";
|
export * from "./listSessionsApiV1GatewaySessionsGetParams";
|
||||||
|
export * from "./listTaskCommentFeedApiV1ActivityTaskCommentsGetParams";
|
||||||
export * from "./listTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetParams";
|
export * from "./listTaskCommentsApiV1AgentBoardsBoardIdTasksTaskIdCommentsGetParams";
|
||||||
export * from "./listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGetParams";
|
export * from "./listTaskCommentsApiV1BoardsBoardIdTasksTaskIdCommentsGetParams";
|
||||||
export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams";
|
export * from "./listTasksApiV1AgentBoardsBoardIdTasksGetParams";
|
||||||
@@ -130,7 +135,9 @@ export * from "./sendSessionMessageApiV1GatewaySessionsSessionIdMessagePostParam
|
|||||||
export * from "./streamAgentsApiV1AgentsStreamGetParams";
|
export * from "./streamAgentsApiV1AgentsStreamGetParams";
|
||||||
export * from "./streamApprovalsApiV1BoardsBoardIdApprovalsStreamGetParams";
|
export * from "./streamApprovalsApiV1BoardsBoardIdApprovalsStreamGetParams";
|
||||||
export * from "./streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGetParams";
|
export * from "./streamBoardMemoryApiV1BoardsBoardIdMemoryStreamGetParams";
|
||||||
|
export * from "./streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGetParams";
|
||||||
export * from "./streamTasksApiV1BoardsBoardIdTasksStreamGetParams";
|
export * from "./streamTasksApiV1BoardsBoardIdTasksStreamGetParams";
|
||||||
|
export * from "./syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams";
|
||||||
export * from "./taskCardRead";
|
export * from "./taskCardRead";
|
||||||
export * from "./taskCardReadStatus";
|
export * from "./taskCardReadStatus";
|
||||||
export * from "./taskCommentCreate";
|
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";
|
"use client";
|
||||||
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
||||||
import {
|
import {
|
||||||
@@ -12,10 +12,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
import { TaskBoard } from "@/components/organisms/TaskBoard";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
@@ -144,118 +142,6 @@ const SSE_RECONNECT_BACKOFF = {
|
|||||||
maxMs: 5 * 60_000,
|
maxMs: 5 * 60_000,
|
||||||
} as const;
|
} 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 formatShortTimestamp = (value: string) => {
|
||||||
const date = parseApiDatetime(value);
|
const date = parseApiDatetime(value);
|
||||||
if (!date) return "—";
|
if (!date) return "—";
|
||||||
@@ -405,9 +291,11 @@ LiveFeedCard.displayName = "LiveFeedCard";
|
|||||||
export default function BoardDetailPage() {
|
export default function BoardDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const boardIdParam = params?.boardId;
|
const boardIdParam = params?.boardId;
|
||||||
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
|
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
|
const taskIdFromUrl = searchParams.get("taskId");
|
||||||
|
|
||||||
const [board, setBoard] = useState<Board | null>(null);
|
const [board, setBoard] = useState<Board | null>(null);
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
@@ -416,6 +304,7 @@ export default function BoardDetailPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
const selectedTaskIdRef = useRef<string | null>(null);
|
const selectedTaskIdRef = useRef<string | null>(null);
|
||||||
|
const openedTaskIdFromUrlRef = useRef<string | null>(null);
|
||||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||||
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
|
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
|
||||||
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
||||||
@@ -1408,6 +1297,15 @@ export default function BoardDetailPage() {
|
|||||||
[loadComments],
|
[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 = () => {
|
const closeComments = () => {
|
||||||
setIsDetailOpen(false);
|
setIsDetailOpen(false);
|
||||||
selectedTaskIdRef.current = null;
|
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 Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
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 { ApiError } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
@@ -81,6 +81,18 @@ export function DashboardSidebar() {
|
|||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
Boards
|
Boards
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href="/agents"
|
href="/agents"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
Reference in New Issue
Block a user