Merge remote-tracking branch 'origin/master' into ishan/fix-activity-clerkprovider

This commit is contained in:
Ishan (OpenClaw)
2026-02-07 10:10:50 +00:00
7 changed files with 748 additions and 7 deletions

View File

@@ -5,6 +5,7 @@ from typing import Any, cast
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import 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
@@ -15,6 +16,7 @@ from app.api import board_onboarding as onboarding_api
from app.api import tasks as tasks_api from app.api import tasks as tasks_api
from app.api.deps import ActorContext, get_board_or_404, get_task_or_404 from app.api.deps import ActorContext, get_board_or_404, get_task_or_404
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context from app.core.agent_auth import AgentAuthContext, get_agent_auth_context
from app.core.config import settings
from app.db.pagination import paginate from app.db.pagination import paginate
from app.db.session import get_session from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
@@ -40,9 +42,19 @@ from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboardingRead from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboardingRead
from app.schemas.boards import BoardRead from app.schemas.boards import BoardRead
from app.schemas.common import OkResponse from app.schemas.common import OkResponse
from app.schemas.gateway_coordination import (
GatewayBoardEnsureRequest,
GatewayBoardEnsureResponse,
GatewayLeadBroadcastBoardResult,
GatewayLeadBroadcastRequest,
GatewayLeadBroadcastResponse,
GatewayLeadMessageRequest,
GatewayLeadMessageResponse,
)
from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity from app.services.activity_log import record_activity
from app.services.board_leads import ensure_board_lead_agent
from app.services.task_dependencies import ( from app.services.task_dependencies import (
blocked_by_dependency_ids, blocked_by_dependency_ids,
dependency_status_by_id, dependency_status_by_id,
@@ -70,6 +82,50 @@ async def _gateway_config(session: AsyncSession, board: Board) -> GatewayClientC
return GatewayClientConfig(url=gateway.url, token=gateway.token) return GatewayClientConfig(url=gateway.url, token=gateway.token)
def _slugify(value: str) -> str:
import re
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "board"
async def _require_gateway_main(
session: AsyncSession,
agent: Agent,
) -> tuple[Gateway, GatewayClientConfig]:
session_key = (agent.openclaw_session_id or "").strip()
if not session_key:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Agent missing session key")
gateway = (
await session.exec(select(Gateway).where(col(Gateway.main_session_key) == session_key))
).first()
if gateway is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the gateway main agent may call this endpoint.",
)
if not gateway.url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway url is required",
)
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
async def _require_gateway_board(
session: AsyncSession,
*,
gateway: Gateway,
board_id: UUID | str,
) -> Board:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
if board.gateway_id != gateway.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return board
@router.get("/boards", response_model=DefaultLimitOffsetPage[BoardRead]) @router.get("/boards", response_model=DefaultLimitOffsetPage[BoardRead])
async def list_boards( async def list_boards(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@@ -440,3 +496,258 @@ async def agent_heartbeat(
session=session, session=session,
actor=_actor(agent_ctx), actor=_actor(agent_ctx),
) )
@router.post("/gateway/boards/ensure", response_model=GatewayBoardEnsureResponse)
async def ensure_gateway_board(
payload: GatewayBoardEnsureRequest,
session: AsyncSession = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> GatewayBoardEnsureResponse:
gateway, config = await _require_gateway_main(session, agent_ctx.agent)
requested_name = payload.name.strip()
requested_slug = _slugify(payload.slug.strip() if payload.slug else requested_name)
# Try slug match first, then case-insensitive name match.
existing = (
await session.exec(
select(Board)
.where(col(Board.gateway_id) == gateway.id)
.where(col(Board.slug) == requested_slug)
)
).first()
if existing is None:
existing = (
await session.exec(
select(Board)
.where(col(Board.gateway_id) == gateway.id)
.where(func.lower(col(Board.name)) == requested_name.lower())
)
).first()
created = False
board = existing
if board is None:
slug = requested_slug
suffix = 2
while True:
conflict = (
await session.exec(
select(Board.id)
.where(col(Board.gateway_id) == gateway.id)
.where(col(Board.slug) == slug)
)
).first()
if conflict is None:
break
slug = f"{requested_slug}-{suffix}"
suffix += 1
board = Board(
name=requested_name,
slug=slug,
gateway_id=gateway.id,
board_type=payload.board_type,
objective=payload.objective.strip() if payload.objective else None,
success_metrics=payload.success_metrics,
target_date=payload.target_date,
goal_confirmed=False,
goal_source="gateway_main_agent",
)
session.add(board)
await session.commit()
await session.refresh(board)
created = True
lead, lead_created = await ensure_board_lead_agent(
session,
board=board,
gateway=gateway,
config=config,
user=None,
agent_name=payload.lead_agent_name.strip() if payload.lead_agent_name else None,
identity_profile=payload.lead_identity_profile,
action="provision",
)
return GatewayBoardEnsureResponse(
created=created,
lead_created=lead_created,
board_id=board.id,
lead_agent_id=lead.id,
board_name=board.name,
board_slug=board.slug,
lead_agent_name=lead.name,
)
@router.post(
"/gateway/boards/{board_id}/lead/message",
response_model=GatewayLeadMessageResponse,
)
async def message_gateway_board_lead(
board_id: UUID,
payload: GatewayLeadMessageRequest,
session: AsyncSession = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> GatewayLeadMessageResponse:
import json
gateway, config = await _require_gateway_main(session, agent_ctx.agent)
board = await _require_gateway_board(session, gateway=gateway, board_id=board_id)
lead, lead_created = await ensure_board_lead_agent(
session,
board=board,
gateway=gateway,
config=config,
user=None,
action="provision",
)
if not lead.openclaw_session_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Lead agent has no session key",
)
base_url = settings.base_url or "http://localhost:8000"
header = "GATEWAY MAIN QUESTION" if payload.kind == "question" else "GATEWAY MAIN HANDOFF"
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
tags = payload.reply_tags or ["gateway_main", "lead_reply"]
tags_json = json.dumps(tags)
reply_source = payload.reply_source or "lead_to_gateway_main"
message = (
f"{header}\n"
f"Board: {board.name}\n"
f"Board ID: {board.id}\n"
f"From agent: {agent_ctx.agent.name}\n"
f"{correlation_line}\n"
f"{payload.content.strip()}\n\n"
"Reply to the gateway main by writing a NON-chat memory item on this board:\n"
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
f'Body: {{"content":"...","tags":{tags_json},"source":"{reply_source}"}}\n'
"Do NOT reply in OpenClaw chat."
)
try:
await ensure_session(lead.openclaw_session_id, config=config, label=lead.name)
await send_message(message, session_key=lead.openclaw_session_id, config=config)
except OpenClawGatewayError as exc:
record_activity(
session,
event_type="gateway.main.lead_message.failed",
message=f"Lead message failed for {board.name}: {exc}",
agent_id=agent_ctx.agent.id,
)
await session.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
record_activity(
session,
event_type="gateway.main.lead_message.sent",
message=f"Sent {payload.kind} to lead for board: {board.name}.",
agent_id=agent_ctx.agent.id,
)
await session.commit()
return GatewayLeadMessageResponse(
board_id=board.id,
lead_agent_id=lead.id,
lead_agent_name=lead.name,
lead_created=lead_created,
)
@router.post(
"/gateway/leads/broadcast",
response_model=GatewayLeadBroadcastResponse,
)
async def broadcast_gateway_lead_message(
payload: GatewayLeadBroadcastRequest,
session: AsyncSession = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> GatewayLeadBroadcastResponse:
import json
gateway, config = await _require_gateway_main(session, agent_ctx.agent)
statement = select(Board).where(col(Board.gateway_id) == gateway.id).order_by(
col(Board.created_at).desc()
)
if payload.board_ids:
statement = statement.where(col(Board.id).in_(payload.board_ids))
boards = list(await session.exec(statement))
base_url = settings.base_url or "http://localhost:8000"
header = "GATEWAY MAIN QUESTION" if payload.kind == "question" else "GATEWAY MAIN HANDOFF"
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
tags = payload.reply_tags or ["gateway_main", "lead_reply"]
tags_json = json.dumps(tags)
reply_source = payload.reply_source or "lead_to_gateway_main"
results: list[GatewayLeadBroadcastBoardResult] = []
sent = 0
failed = 0
for board in boards:
try:
lead, _lead_created = await ensure_board_lead_agent(
session,
board=board,
gateway=gateway,
config=config,
user=None,
action="provision",
)
if not lead.openclaw_session_id:
raise ValueError("Lead agent has no session key")
message = (
f"{header}\n"
f"Board: {board.name}\n"
f"Board ID: {board.id}\n"
f"From agent: {agent_ctx.agent.name}\n"
f"{correlation_line}\n"
f"{payload.content.strip()}\n\n"
"Reply to the gateway main by writing a NON-chat memory item on this board:\n"
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
f'Body: {{"content":"...","tags":{tags_json},"source":"{reply_source}"}}\n'
"Do NOT reply in OpenClaw chat."
)
await ensure_session(lead.openclaw_session_id, config=config, label=lead.name)
await send_message(message, session_key=lead.openclaw_session_id, config=config)
results.append(
GatewayLeadBroadcastBoardResult(
board_id=board.id,
lead_agent_id=lead.id,
lead_agent_name=lead.name,
ok=True,
)
)
sent += 1
except Exception as exc:
results.append(
GatewayLeadBroadcastBoardResult(
board_id=board.id,
ok=False,
error=str(exc),
)
)
failed += 1
record_activity(
session,
event_type="gateway.main.lead_broadcast.sent",
message=f"Broadcast {payload.kind} to {sent} board leads (failed: {failed}).",
agent_id=agent_ctx.agent.id,
)
await session.commit()
return GatewayLeadBroadcastResponse(
ok=True,
sent=sent,
failed=failed,
results=results,
)

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from sqlmodel import Field, SQLModel
from app.schemas.common import NonEmptyStr
class GatewayBoardEnsureRequest(SQLModel):
name: NonEmptyStr
slug: str | None = None
board_type: Literal["goal", "general"] = "goal"
objective: str | None = None
success_metrics: dict[str, object] | None = None
target_date: datetime | None = None
lead_agent_name: str | None = None
lead_identity_profile: dict[str, str] | None = None
class GatewayBoardEnsureResponse(SQLModel):
created: bool = False
lead_created: bool = False
board_id: UUID
lead_agent_id: UUID | None = None
# Convenience fields for callers that don't want to re-fetch.
board_name: str
board_slug: str
lead_agent_name: str | None = None
class GatewayLeadMessageRequest(SQLModel):
kind: Literal["question", "handoff"] = "question"
correlation_id: str | None = None
content: NonEmptyStr
# How the lead should reply (defaults are interpreted by templates).
reply_tags: list[str] = Field(default_factory=lambda: ["gateway_main", "lead_reply"])
reply_source: str | None = "lead_to_gateway_main"
class GatewayLeadMessageResponse(SQLModel):
ok: bool = True
board_id: UUID
lead_agent_id: UUID | None = None
lead_agent_name: str | None = None
lead_created: bool = False
class GatewayLeadBroadcastRequest(SQLModel):
kind: Literal["question", "handoff"] = "question"
correlation_id: str | None = None
content: NonEmptyStr
board_ids: list[UUID] | None = None
reply_tags: list[str] = Field(default_factory=lambda: ["gateway_main", "lead_reply"])
reply_source: str | None = "lead_to_gateway_main"
class GatewayLeadBroadcastBoardResult(SQLModel):
board_id: UUID
lead_agent_id: UUID | None = None
lead_agent_name: str | None = None
ok: bool = False
error: str | None = None
class GatewayLeadBroadcastResponse(SQLModel):
ok: bool = True
sent: int = 0
failed: int = 0
results: list[GatewayLeadBroadcastBoardResult] = Field(default_factory=list)

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
from typing import Any
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.time import utcnow
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.users import User
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_agent
def lead_session_key(board: Board) -> str:
return f"agent:lead-{board.id}:main"
def lead_agent_name(_: Board) -> str:
return "Lead Agent"
async def ensure_board_lead_agent(
session: AsyncSession,
*,
board: Board,
gateway: Gateway,
config: GatewayClientConfig,
user: User | None,
agent_name: str | None = None,
identity_profile: dict[str, str] | None = None,
action: str = "provision",
) -> tuple[Agent, bool]:
existing = (
await session.exec(
select(Agent)
.where(Agent.board_id == board.id)
.where(col(Agent.is_board_lead).is_(True))
)
).first()
if existing:
desired_name = agent_name or lead_agent_name(board)
changed = False
if existing.name != desired_name:
existing.name = desired_name
changed = True
desired_session_key = lead_session_key(board)
if not existing.openclaw_session_id:
existing.openclaw_session_id = desired_session_key
changed = True
if changed:
existing.updated_at = utcnow()
session.add(existing)
await session.commit()
await session.refresh(existing)
return existing, False
merged_identity_profile: dict[str, Any] = {
"role": "Board Lead",
"communication_style": "direct, concise, practical",
"emoji": ":gear:",
}
if identity_profile:
merged_identity_profile.update(
{key: value.strip() for key, value in identity_profile.items() if value.strip()}
)
agent = Agent(
name=agent_name or lead_agent_name(board),
status="provisioning",
board_id=board.id,
is_board_lead=True,
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
identity_profile=merged_identity_profile,
openclaw_session_id=lead_session_key(board),
provision_requested_at=utcnow(),
provision_action=action,
)
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
session.add(agent)
await session.commit()
await session.refresh(agent)
try:
await provision_agent(agent, board, gateway, raw_token, user, action=action)
if agent.openclaw_session_id:
await ensure_session(agent.openclaw_session_id, config=config, label=agent.name)
await send_message(
(
f"Hello {agent.name}. Your workspace has been provisioned.\n\n"
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once "
"then delete it. Begin heartbeats after startup."
),
session_key=agent.openclaw_session_id,
config=config,
deliver=True,
)
except OpenClawGatewayError:
# Best-effort provisioning. The board/agent rows should still exist.
pass
return agent, True

View File

@@ -50,7 +50,10 @@ import {
streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet, streamApprovalsApiV1BoardsBoardIdApprovalsStreamGet,
updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch, updateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
} from "@/api/generated/approvals/approvals"; } from "@/api/generated/approvals/approvals";
import { listTaskCommentFeedApiV1ActivityTaskCommentsGet } from "@/api/generated/activity/activity"; import {
listTaskCommentFeedApiV1ActivityTaskCommentsGet,
streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet,
} from "@/api/generated/activity/activity";
import { getBoardSnapshotApiV1BoardsBoardIdSnapshotGet } from "@/api/generated/boards/boards"; import { getBoardSnapshotApiV1BoardsBoardIdSnapshotGet } from "@/api/generated/boards/boards";
import { import {
createBoardMemoryApiV1BoardsBoardIdMemoryPost, createBoardMemoryApiV1BoardsBoardIdMemoryPost,
@@ -219,6 +222,7 @@ const LiveFeedCard = memo(function LiveFeedCard({
authorRole, authorRole,
authorAvatar, authorAvatar,
onViewTask, onViewTask,
isNew,
}: { }: {
comment: TaskComment; comment: TaskComment;
taskTitle: string; taskTitle: string;
@@ -226,10 +230,18 @@ const LiveFeedCard = memo(function LiveFeedCard({
authorRole?: string | null; authorRole?: string | null;
authorAvatar: string; authorAvatar: string;
onViewTask?: () => void; onViewTask?: () => void;
isNew?: boolean;
}) { }) {
const message = (comment.message ?? "").trim(); const message = (comment.message ?? "").trim();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white p-3 transition hover:border-slate-300"> <div
className={cn(
"rounded-xl border p-3 transition-colors duration-300",
isNew
? "border-blue-200 bg-blue-50/70 shadow-sm hover:border-blue-300 motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-95 motion-safe:slide-in-from-right-2 motion-safe:duration-300"
: "border-slate-200 bg-white hover:border-slate-300",
)}
>
<div className="flex items-start gap-3"> <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"> <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} {authorAvatar}
@@ -316,6 +328,11 @@ export default function BoardDetailPage() {
const openedTaskIdFromUrlRef = useRef<string | null>(null); const openedTaskIdFromUrlRef = useRef<string | null>(null);
const [comments, setComments] = useState<TaskComment[]>([]); const [comments, setComments] = useState<TaskComment[]>([]);
const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]); const [liveFeed, setLiveFeed] = useState<TaskComment[]>([]);
const liveFeedRef = useRef<TaskComment[]>([]);
const liveFeedFlashTimersRef = useRef<Record<string, number>>({});
const [liveFeedFlashIds, setLiveFeedFlashIds] = useState<
Record<string, boolean>
>({});
const [isLiveFeedHistoryLoading, setIsLiveFeedHistoryLoading] = const [isLiveFeedHistoryLoading, setIsLiveFeedHistoryLoading] =
useState(false); useState(false);
const [liveFeedHistoryError, setLiveFeedHistoryError] = useState< const [liveFeedHistoryError, setLiveFeedHistoryError] = useState<
@@ -359,7 +376,9 @@ export default function BoardDetailPage() {
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null); const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [viewMode, setViewMode] = useState<"board" | "list">("board");
const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false); const [isLiveFeedOpen, setIsLiveFeedOpen] = useState(false);
const isLiveFeedOpenRef = useRef(false);
const pushLiveFeed = useCallback((comment: TaskComment) => { const pushLiveFeed = useCallback((comment: TaskComment) => {
const alreadySeen = liveFeedRef.current.some((item) => item.id === comment.id);
setLiveFeed((prev) => { setLiveFeed((prev) => {
if (prev.some((item) => item.id === comment.id)) { if (prev.some((item) => item.id === comment.id)) {
return prev; return prev;
@@ -367,6 +386,28 @@ export default function BoardDetailPage() {
const next = [comment, ...prev]; const next = [comment, ...prev];
return next.slice(0, 50); return next.slice(0, 50);
}); });
if (alreadySeen) return;
if (!isLiveFeedOpenRef.current) return;
setLiveFeedFlashIds((prev) =>
prev[comment.id] ? prev : { ...prev, [comment.id]: true },
);
if (typeof window === "undefined") return;
const existingTimer = liveFeedFlashTimersRef.current[comment.id];
if (existingTimer !== undefined) {
window.clearTimeout(existingTimer);
}
liveFeedFlashTimersRef.current[comment.id] = window.setTimeout(() => {
delete liveFeedFlashTimersRef.current[comment.id];
setLiveFeedFlashIds((prev) => {
if (!prev[comment.id]) return prev;
const next = { ...prev };
delete next[comment.id];
return next;
});
}, 2200);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -374,8 +415,26 @@ export default function BoardDetailPage() {
setIsLiveFeedHistoryLoading(false); setIsLiveFeedHistoryLoading(false);
setLiveFeedHistoryError(null); setLiveFeedHistoryError(null);
setLiveFeed([]); setLiveFeed([]);
setLiveFeedFlashIds({});
if (typeof window !== "undefined") {
Object.values(liveFeedFlashTimersRef.current).forEach((timerId) => {
window.clearTimeout(timerId);
});
}
liveFeedFlashTimersRef.current = {};
}, [boardId]); }, [boardId]);
useEffect(() => {
return () => {
if (typeof window !== "undefined") {
Object.values(liveFeedFlashTimersRef.current).forEach((timerId) => {
window.clearTimeout(timerId);
});
}
liveFeedFlashTimersRef.current = {};
};
}, []);
useEffect(() => { useEffect(() => {
if (!isLiveFeedOpen) return; if (!isLiveFeedOpen) return;
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
@@ -576,6 +635,14 @@ export default function BoardDetailPage() {
chatMessagesRef.current = chatMessages; chatMessagesRef.current = chatMessages;
}, [chatMessages]); }, [chatMessages]);
useEffect(() => {
liveFeedRef.current = liveFeed;
}, [liveFeed]);
useEffect(() => {
isLiveFeedOpenRef.current = isLiveFeedOpen;
}, [isLiveFeedOpen]);
useEffect(() => { useEffect(() => {
if (!isChatOpen) return; if (!isChatOpen) return;
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
@@ -716,6 +783,125 @@ export default function BoardDetailPage() {
}; };
}, [board, boardId, isChatOpen, isPageActive, isSignedIn]); }, [board, boardId, isChatOpen, isPageActive, isSignedIn]);
useEffect(() => {
if (!isPageActive) return;
if (!isLiveFeedOpen) return;
if (!isSignedIn || !boardId) return;
let isCancelled = false;
const abortController = new AbortController();
const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF);
let reconnectTimeout: number | undefined;
const connect = async () => {
try {
const since = (() => {
let latestTime = 0;
liveFeedRef.current.forEach((comment) => {
const time = apiDatetimeToMs(comment.created_at);
if (time !== null && time > latestTime) {
latestTime = time;
}
});
return latestTime ? new Date(latestTime).toISOString() : null;
})();
const streamResult =
await streamTaskCommentFeedApiV1ActivityTaskCommentsStreamGet(
{
board_id: boardId,
since: since ?? null,
},
{
headers: { Accept: "text/event-stream" },
signal: abortController.signal,
},
);
if (streamResult.status !== 200) {
throw new Error("Unable to connect live feed stream.");
}
const response = streamResult.data as Response;
if (!(response instanceof Response) || !response.body) {
throw new Error("Unable to connect live feed stream.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!isCancelled) {
const { value, done } = await reader.read();
if (done) break;
if (value && value.length) {
backoff.reset();
}
buffer += decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, "\n");
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const raw = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const lines = raw.split("\n");
let eventType = "message";
let data = "";
for (const line of lines) {
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
} else if (line.startsWith("data:")) {
data += line.slice(5).trim();
}
}
if (eventType === "comment" && data) {
try {
const payload = JSON.parse(data) as {
comment?: {
id: string;
created_at: string;
message?: string | null;
agent_id?: string | null;
task_id?: string | null;
};
};
if (payload.comment) {
pushLiveFeed({
id: payload.comment.id,
created_at: payload.comment.created_at,
message: payload.comment.message ?? null,
agent_id: payload.comment.agent_id ?? null,
task_id: payload.comment.task_id ?? null,
});
}
} catch {
// ignore malformed
}
}
boundary = buffer.indexOf("\n\n");
}
}
} catch {
// Reconnect handled below.
}
if (!isCancelled) {
if (reconnectTimeout !== undefined) {
window.clearTimeout(reconnectTimeout);
}
const delay = backoff.nextDelayMs();
reconnectTimeout = window.setTimeout(() => {
reconnectTimeout = undefined;
void connect();
}, delay);
}
};
void connect();
return () => {
isCancelled = true;
abortController.abort();
if (reconnectTimeout !== undefined) {
window.clearTimeout(reconnectTimeout);
}
};
}, [boardId, isLiveFeedOpen, isPageActive, isSignedIn, pushLiveFeed]);
useEffect(() => { useEffect(() => {
if (!isPageActive) return; if (!isPageActive) return;
if (!isSignedIn || !boardId || !board) return; if (!isSignedIn || !boardId || !board) return;
@@ -949,18 +1135,23 @@ export default function BoardDetailPage() {
payload.comment?.created_at, payload.comment?.created_at,
); );
if (prev.length === 0 || createdMs === null) { if (prev.length === 0 || createdMs === null) {
return [...prev, payload.comment as TaskComment]; return [payload.comment as TaskComment, ...prev];
}
const first = prev[0];
const firstMs = apiDatetimeToMs(first?.created_at);
if (firstMs !== null && createdMs >= firstMs) {
return [payload.comment as TaskComment, ...prev];
} }
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
const lastMs = apiDatetimeToMs(last?.created_at); const lastMs = apiDatetimeToMs(last?.created_at);
if (lastMs !== null && createdMs >= lastMs) { if (lastMs !== null && createdMs <= lastMs) {
return [...prev, payload.comment as TaskComment]; return [...prev, payload.comment as TaskComment];
} }
const next = [...prev, payload.comment as TaskComment]; const next = [...prev, payload.comment as TaskComment];
next.sort((a, b) => { next.sort((a, b) => {
const aTime = apiDatetimeToMs(a.created_at) ?? 0; const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = apiDatetimeToMs(b.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0;
return aTime - bTime; return bTime - aTime;
}); });
return next; return next;
}); });
@@ -1421,7 +1612,7 @@ export default function BoardDetailPage() {
items.sort((a, b) => { items.sort((a, b) => {
const aTime = apiDatetimeToMs(a.created_at) ?? 0; const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = apiDatetimeToMs(b.created_at) ?? 0; const bTime = apiDatetimeToMs(b.created_at) ?? 0;
return aTime - bTime; return bTime - aTime;
}); });
setComments(items); setComments(items);
} catch (err) { } catch (err) {
@@ -2573,6 +2764,7 @@ export default function BoardDetailPage() {
<LiveFeedCard <LiveFeedCard
key={comment.id} key={comment.id}
comment={comment} comment={comment}
isNew={Boolean(liveFeedFlashIds[comment.id])}
taskTitle={ taskTitle={
taskId ? (taskTitleById.get(taskId) ?? "Task") : "Task" taskId ? (taskTitleById.get(taskId) ?? "Task") : "Task"
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { memo, useCallback, useState } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -17,12 +17,22 @@ function BoardChatComposerImpl({
onSend, onSend,
}: BoardChatComposerProps) { }: BoardChatComposerProps) {
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const shouldFocusAfterSendRef = useRef(false);
useEffect(() => {
if (isSending) return;
if (!shouldFocusAfterSendRef.current) return;
shouldFocusAfterSendRef.current = false;
textareaRef.current?.focus();
}, [isSending]);
const send = useCallback(async () => { const send = useCallback(async () => {
if (isSending) return; if (isSending) return;
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return; if (!trimmed) return;
const ok = await onSend(trimmed); const ok = await onSend(trimmed);
shouldFocusAfterSendRef.current = true;
if (ok) { if (ok) {
setValue(""); setValue("");
} }
@@ -31,6 +41,7 @@ function BoardChatComposerImpl({
return ( return (
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<Textarea <Textarea
ref={textareaRef}
value={value} value={value}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {

View File

@@ -66,6 +66,15 @@ Comment template (keep it small; 1-3 bullets per section; omit what is not appli
- Board chat is your primary channel with the human; respond promptly and clearly. - Board chat is your primary channel with the human; respond promptly and clearly.
- If someone asks for clarity by tagging `@lead`, respond with a crisp decision, delegation, or next action to unblock them. - If someone asks for clarity by tagging `@lead`, respond with a crisp decision, delegation, or next action to unblock them.
## Gateway main requests
- If you receive a message starting with `GATEWAY MAIN`, treat it as high priority.
- Do **not** reply in OpenClaw chat. Reply via Mission Control only.
- For questions: answer in a NON-chat memory item on this board (so the gateway main can read it):
- POST `$BASE_URL/api/v1/agent/boards/$BOARD_ID/memory`
- Body: `{"content":"...","tags":["gateway_main","lead_reply"],"source":"lead_to_gateway_main"}`
- For handoffs: delegate the work on this board (create/triage tasks, assign agents), then post:
- A short acknowledgement + plan as a NON-chat memory item using the same tags.
## Mission Control Response Protocol (mandatory) ## Mission Control Response Protocol (mandatory)
- All outputs must be sent to Mission Control via HTTP. - All outputs must be sent to Mission Control via HTTP.
- Always include: `X-Agent-Token: {{ auth_token }}` - Always include: `X-Agent-Token: {{ auth_token }}`

View File

@@ -27,6 +27,41 @@ Do this immediately. Do not ask permission to read your workspace.
- You help with onboarding and gateway-wide requests. - You help with onboarding and gateway-wide requests.
- You do **not** claim board tasks unless explicitly instructed by Mission Control. - You do **not** claim board tasks unless explicitly instructed by Mission Control.
## Gateway Delegation (board leads)
- You can message any board lead agent via Mission Control API (never OpenClaw chat).
- If the requested board does not exist, you must create it and provision its lead agent first.
- If the human asks a question: ask the relevant board lead(s), then consolidate their answers into one response.
- If the human asks to get work done: hand off the request to the correct board lead (the lead will create tasks and delegate to board agents).
Ensure (create if needed) a board + lead:
```bash
curl -s -X POST "$BASE_URL/api/v1/agent/gateway/boards/ensure" \
-H "X-Agent-Token: $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"<Board Name>","slug":"<optional-slug>","board_type":"goal","objective":"<optional>","success_metrics":null,"target_date":null}'
```
Send a question or handoff to a board lead:
```bash
curl -s -X POST "$BASE_URL/api/v1/agent/gateway/boards/<BOARD_ID>/lead/message" \
-H "X-Agent-Token: $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind":"question","correlation_id":"<optional>","content":"..."}'
```
Broadcast to all board leads in this gateway:
```bash
curl -s -X POST "$BASE_URL/api/v1/agent/gateway/leads/broadcast" \
-H "X-Agent-Token: $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind":"question","correlation_id":"<optional>","content":"..."}'
```
Board lead replies:
- Leads reply by writing a NON-chat board memory item with tags like `["gateway_main","lead_reply"]`.
- Read replies via:
- GET `$BASE_URL/api/v1/agent/boards/<BOARD_ID>/memory?is_chat=false&limit=50`
## Tools ## Tools
- Skills are authoritative. Follow SKILL.md instructions exactly. - Skills are authoritative. Follow SKILL.md instructions exactly.
- Use TOOLS.md for environment-specific notes. - Use TOOLS.md for environment-specific notes.