feat(board): add agent control dialog to pause and resume agents

This commit is contained in:
Abhimanyu Saharan
2026-02-07 13:30:15 +05:30
parent 3079a92492
commit a7175d9a6f
2 changed files with 203 additions and 19 deletions

View File

@@ -65,9 +65,10 @@ async def _send_agent_message(
config: GatewayClientConfig,
agent_name: str,
message: str,
deliver: bool = False,
) -> None:
await ensure_session(session_key, config=config, label=agent_name)
await send_message(message, session_key=session_key, config=config, deliver=False)
await send_message(message, session_key=session_key, config=config, deliver=deliver)
async def _fetch_memory_events(
@@ -102,6 +103,31 @@ async def _notify_chat_targets(
config = await _gateway_config(session, board)
if config is None:
return
normalized = memory.content.strip()
command = normalized.lower()
# Special-case control commands to reach all board agents.
# These are intended to be parsed verbatim by agent runtimes.
if command in {"/pause", "/resume"}:
statement = select(Agent).where(col(Agent.board_id) == board.id)
targets = list(await session.exec(statement))
for agent in targets:
if actor.actor_type == "agent" and actor.agent and agent.id == actor.agent.id:
continue
if not agent.openclaw_session_id:
continue
try:
await _send_agent_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
message=command,
deliver=True,
)
except OpenClawGatewayError:
continue
return
mentions = extract_mentions(memory.content)
statement = select(Agent).where(col(Agent.board_id) == board.id)
targets: dict[str, Agent] = {}

View File

@@ -10,8 +10,12 @@ import {
Activity,
ArrowUpRight,
MessageSquare,
Pause,
Plus,
Pencil,
Play,
Settings,
ShieldCheck,
X,
} from "lucide-react";
@@ -342,6 +346,15 @@ export default function BoardDetailPage() {
const [chatError, setChatError] = useState<string | null>(null);
const chatMessagesRef = useRef<BoardChatMessage[]>([]);
const chatEndRef = useRef<HTMLDivElement | null>(null);
const [isAgentsControlDialogOpen, setIsAgentsControlDialogOpen] =
useState(false);
const [agentsControlAction, setAgentsControlAction] = useState<
"pause" | "resume"
>("pause");
const [isAgentsControlSending, setIsAgentsControlSending] = useState(false);
const [agentsControlError, setAgentsControlError] = useState<string | null>(
null,
);
const [isDeletingTask, setIsDeletingTask] = useState(false);
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board");
@@ -581,6 +594,18 @@ export default function BoardDetailPage() {
return new Date(latest).toISOString();
};
const lastAgentControlCommand = useMemo(() => {
for (let i = chatMessages.length - 1; i >= 0; i -= 1) {
const value = (chatMessages[i]?.content ?? "").trim().toLowerCase();
if (value === "/pause" || value === "/resume") {
return value;
}
}
return null;
}, [chatMessages]);
const isAgentsPaused = lastAgentControlCommand === "/pause";
useEffect(() => {
if (!isPageActive) return;
if (!isSignedIn || !boardId || !board) return;
@@ -1163,13 +1188,16 @@ export default function BoardDetailPage() {
}
};
const handleSendChat = useCallback(
async (content: string): Promise<boolean> => {
if (!isSignedIn || !boardId) return false;
const postBoardChatMessage = useCallback(
async (
content: string,
): Promise<{ ok: boolean; error: string | null }> => {
if (!isSignedIn || !boardId) {
return { ok: false, error: "Sign in to send messages." };
}
const trimmed = content.trim();
if (!trimmed) return false;
setIsChatSending(true);
setChatError(null);
if (!trimmed) return { ok: false, error: null };
try {
const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost(
boardId,
@@ -1195,19 +1223,62 @@ export default function BoardDetailPage() {
return next;
});
}
return true;
return { ok: true, error: null };
} catch (err) {
setChatError(
err instanceof Error ? err.message : "Unable to send message.",
);
return false;
} finally {
setIsChatSending(false);
const message =
err instanceof Error ? err.message : "Unable to send message.";
return { ok: false, error: message };
}
},
[boardId, isSignedIn],
);
const handleSendChat = useCallback(
async (content: string): Promise<boolean> => {
const trimmed = content.trim();
if (!trimmed) return false;
setIsChatSending(true);
setChatError(null);
try {
const result = await postBoardChatMessage(trimmed);
if (!result.ok) {
if (result.error) {
setChatError(result.error);
}
return false;
}
return true;
} finally {
setIsChatSending(false);
}
},
[postBoardChatMessage],
);
const openAgentsControlDialog = (action: "pause" | "resume") => {
setAgentsControlAction(action);
setAgentsControlError(null);
setIsAgentsControlDialogOpen(true);
};
const handleConfirmAgentsControl = useCallback(async () => {
const command = agentsControlAction === "pause" ? "/pause" : "/resume";
setIsAgentsControlSending(true);
setAgentsControlError(null);
try {
const result = await postBoardChatMessage(command);
if (!result.ok) {
setAgentsControlError(
result.error ?? `Unable to send ${command} command.`,
);
return;
}
setIsAgentsControlDialogOpen(false);
} finally {
setIsAgentsControlSending(false);
}
}, [agentsControlAction, postBoardChatMessage]);
const assigneeById = useMemo(() => {
const map = new Map<string, string>();
agents
@@ -1869,21 +1940,48 @@ export default function BoardDetailPage() {
List
</button>
</div>
<Button onClick={() => setIsDialogOpen(true)}>
New task
<Button
onClick={() => setIsDialogOpen(true)}
className="h-9 w-9 p-0"
aria-label="New task"
title="New task"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => router.push(`/boards/${boardId}/approvals`)}
className="relative"
className="relative h-9 w-9 p-0"
aria-label="Approvals"
title="Approvals"
>
Approvals
<ShieldCheck className="h-4 w-4" />
{pendingApprovals.length > 0 ? (
<span className="ml-2 inline-flex min-w-[20px] items-center justify-center rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold text-white">
<span className="absolute -right-1 -top-1 inline-flex min-w-[18px] items-center justify-center rounded-full bg-slate-900 px-1.5 py-0.5 text-[10px] font-semibold text-white">
{pendingApprovals.length}
</span>
) : null}
</Button>
<Button
variant="outline"
onClick={() =>
openAgentsControlDialog(
isAgentsPaused ? "resume" : "pause",
)
}
disabled={!isSignedIn || !boardId || isAgentsControlSending}
className={cn("h-9 w-9 p-0", isAgentsPaused
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-800"
: "")}
aria-label={isAgentsPaused ? "Resume agents" : "Pause agents"}
title={isAgentsPaused ? "Resume agents" : "Pause agents"}
>
{isAgentsPaused ? (
<Play className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
onClick={openBoardChat}
@@ -2799,6 +2897,66 @@ export default function BoardDetailPage() {
</DialogContent>
</Dialog>
<Dialog
open={isAgentsControlDialogOpen}
onOpenChange={(nextOpen) => {
setIsAgentsControlDialogOpen(nextOpen);
if (!nextOpen) {
setAgentsControlError(null);
}
}}
>
<DialogContent aria-label="Agent controls">
<DialogHeader>
<DialogTitle>
{agentsControlAction === "pause" ? "Pause agents" : "Resume agents"}
</DialogTitle>
<DialogDescription>
{agentsControlAction === "pause"
? "Send /pause to every agent on this board."
: "Send /resume to every agent on this board."}
</DialogDescription>
</DialogHeader>
{agentsControlError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{agentsControlError}
</div>
) : null}
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
<p className="font-semibold text-slate-900">What happens</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>
This posts{" "}
<span className="font-mono">
{agentsControlAction === "pause" ? "/pause" : "/resume"}
</span>{" "}
to board chat.
</li>
<li>Mission Control forwards it to all agents on this board.</li>
</ul>
</div>
<DialogFooter className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setIsAgentsControlDialogOpen(false)}
disabled={isAgentsControlSending}
>
Cancel
</Button>
<Button onClick={handleConfirmAgentsControl} disabled={isAgentsControlSending}>
{isAgentsControlSending
? "Sending…"
: agentsControlAction === "pause"
? "Pause agents"
: "Resume agents"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* onboarding moved to board settings */}
</DashboardShell>
);