feat(board): add agent control dialog to pause and resume agents
This commit is contained in:
@@ -65,9 +65,10 @@ async def _send_agent_message(
|
|||||||
config: GatewayClientConfig,
|
config: GatewayClientConfig,
|
||||||
agent_name: str,
|
agent_name: str,
|
||||||
message: str,
|
message: str,
|
||||||
|
deliver: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
await ensure_session(session_key, config=config, label=agent_name)
|
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(
|
async def _fetch_memory_events(
|
||||||
@@ -102,6 +103,31 @@ async def _notify_chat_targets(
|
|||||||
config = await _gateway_config(session, board)
|
config = await _gateway_config(session, board)
|
||||||
if config is None:
|
if config is None:
|
||||||
return
|
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)
|
mentions = extract_mentions(memory.content)
|
||||||
statement = select(Agent).where(col(Agent.board_id) == board.id)
|
statement = select(Agent).where(col(Agent.board_id) == board.id)
|
||||||
targets: dict[str, Agent] = {}
|
targets: dict[str, Agent] = {}
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Pause,
|
||||||
|
Plus,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Play,
|
||||||
Settings,
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -342,6 +346,15 @@ export default function BoardDetailPage() {
|
|||||||
const [chatError, setChatError] = useState<string | null>(null);
|
const [chatError, setChatError] = useState<string | null>(null);
|
||||||
const chatMessagesRef = useRef<BoardChatMessage[]>([]);
|
const chatMessagesRef = useRef<BoardChatMessage[]>([]);
|
||||||
const chatEndRef = useRef<HTMLDivElement | null>(null);
|
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 [isDeletingTask, setIsDeletingTask] = useState(false);
|
||||||
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");
|
||||||
@@ -581,6 +594,18 @@ export default function BoardDetailPage() {
|
|||||||
return new Date(latest).toISOString();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isPageActive) return;
|
if (!isPageActive) return;
|
||||||
if (!isSignedIn || !boardId || !board) return;
|
if (!isSignedIn || !boardId || !board) return;
|
||||||
@@ -1163,13 +1188,16 @@ export default function BoardDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendChat = useCallback(
|
const postBoardChatMessage = useCallback(
|
||||||
async (content: string): Promise<boolean> => {
|
async (
|
||||||
if (!isSignedIn || !boardId) return false;
|
content: string,
|
||||||
|
): Promise<{ ok: boolean; error: string | null }> => {
|
||||||
|
if (!isSignedIn || !boardId) {
|
||||||
|
return { ok: false, error: "Sign in to send messages." };
|
||||||
|
}
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) return { ok: false, error: null };
|
||||||
setIsChatSending(true);
|
|
||||||
setChatError(null);
|
|
||||||
try {
|
try {
|
||||||
const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost(
|
const result = await createBoardMemoryApiV1BoardsBoardIdMemoryPost(
|
||||||
boardId,
|
boardId,
|
||||||
@@ -1195,19 +1223,62 @@ export default function BoardDetailPage() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return { ok: true, error: null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setChatError(
|
const message =
|
||||||
err instanceof Error ? err.message : "Unable to send message.",
|
err instanceof Error ? err.message : "Unable to send message.";
|
||||||
);
|
return { ok: false, error: message };
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setIsChatSending(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[boardId, isSignedIn],
|
[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 assigneeById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
agents
|
agents
|
||||||
@@ -1869,21 +1940,48 @@ export default function BoardDetailPage() {
|
|||||||
List
|
List
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setIsDialogOpen(true)}>
|
<Button
|
||||||
New task
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push(`/boards/${boardId}/approvals`)}
|
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 ? (
|
{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}
|
{pendingApprovals.length}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={openBoardChat}
|
onClick={openBoardChat}
|
||||||
@@ -2799,6 +2897,66 @@ export default function BoardDetailPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* onboarding moved to board settings */}
|
||||||
</DashboardShell>
|
</DashboardShell>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user