From 24bcc5aea7f5027eb05e2620dad2399ec6bdc1be Mon Sep 17 00:00:00 2001 From: Richard Tang Date: Mon, 20 Apr 2026 11:19:57 -0700 Subject: [PATCH] feat: update trigger ui --- core/framework/agent_loop/conversation.py | 10 ++++ .../internals/cursor_persistence.py | 4 +- core/framework/server/routes_events.py | 1 + core/framework/tools/queen_lifecycle_tools.py | 8 +++ core/frontend/src/components/ChatPanel.tsx | 51 ++++++++++++++++++- core/frontend/src/lib/chat-helpers.ts | 28 ++++++++++ core/frontend/src/pages/colony-chat.tsx | 23 +++++++++ 7 files changed, 123 insertions(+), 2 deletions(-) diff --git a/core/framework/agent_loop/conversation.py b/core/framework/agent_loop/conversation.py index 49410bbd..36fe6542 100644 --- a/core/framework/agent_loop/conversation.py +++ b/core/framework/agent_loop/conversation.py @@ -61,6 +61,11 @@ class Message: # compacted-summary message it writes when a colony is born from a # queen DM. Presence of the field IS the "inherited" signal. inherited_from: str | None = None + # True when this user message was synthesized from one or more + # fired triggers (timer/webhook), not typed by a human. The LLM still + # sees the message as a regular user turn; the UI uses this flag to + # render it as a trigger banner instead of a speech bubble. + is_trigger: bool = False def to_llm_dict(self) -> dict[str, Any]: """Convert to OpenAI-format message dict.""" @@ -128,6 +133,8 @@ class Message: d["truncated"] = self.truncated if self.inherited_from is not None: d["inherited_from"] = self.inherited_from + if self.is_trigger: + d["is_trigger"] = self.is_trigger return d @classmethod @@ -148,6 +155,7 @@ class Message: is_system_nudge=data.get("is_system_nudge", False), truncated=data.get("truncated", False), inherited_from=data.get("inherited_from"), + is_trigger=data.get("is_trigger", False), ) @@ -493,6 +501,7 @@ class NodeConversation: is_client_input: bool = False, image_content: list[dict[str, Any]] | None = None, is_system_nudge: bool = False, + is_trigger: bool = False, ) -> Message: msg = Message( seq=self._next_seq, @@ -504,6 +513,7 @@ class NodeConversation: is_client_input=is_client_input, image_content=image_content, is_system_nudge=is_system_nudge, + is_trigger=is_trigger, ) self._messages.append(msg) self._next_seq += 1 diff --git a/core/framework/agent_loop/internals/cursor_persistence.py b/core/framework/agent_loop/internals/cursor_persistence.py index 06060222..aa40de61 100644 --- a/core/framework/agent_loop/internals/cursor_persistence.py +++ b/core/framework/agent_loop/internals/cursor_persistence.py @@ -234,7 +234,9 @@ async def drain_trigger_queue( combined = "\n\n".join(parts) logger.info("[drain] %d trigger(s): %s", len(triggers), combined[:200]) - await conversation.add_user_message(combined) + # Tag the message so the UI can render a banner instead of the raw + # `[TRIGGER: ...]` text. The LLM still sees `combined` verbatim. + await conversation.add_user_message(combined, is_trigger=True) return len(triggers) diff --git a/core/framework/server/routes_events.py b/core/framework/server/routes_events.py index 30e68dc9..be72c929 100644 --- a/core/framework/server/routes_events.py +++ b/core/framework/server/routes_events.py @@ -209,6 +209,7 @@ async def handle_events(request: web.Request) -> web.StreamResponse: EventType.TRIGGER_AVAILABLE.value, EventType.TRIGGER_ACTIVATED.value, EventType.TRIGGER_DEACTIVATED.value, + EventType.TRIGGER_FIRED.value, EventType.TRIGGER_REMOVED.value, EventType.TRIGGER_UPDATED.value, } diff --git a/core/framework/tools/queen_lifecycle_tools.py b/core/framework/tools/queen_lifecycle_tools.py index 53988485..272728d3 100644 --- a/core/framework/tools/queen_lifecycle_tools.py +++ b/core/framework/tools/queen_lifecycle_tools.py @@ -493,9 +493,17 @@ async def _emit_trigger_fired(session: Any, trigger_id: str, trigger_type: str) from framework.host.event_bus import AgentEvent, EventType + # Pull the task/description off the trigger definition so the chat + # banner can render something human-readable without a second fetch. + tdef = getattr(session, "available_triggers", {}).get(trigger_id) + task_str = getattr(tdef, "task", "") or "" if tdef else "" + name_str = getattr(tdef, "description", "") or trigger_id if tdef else trigger_id + data: dict[str, Any] = { "trigger_id": trigger_id, "trigger_type": trigger_type, + "name": name_str, + "task": task_str, "last_fired_at": last_fired_at, } if fire_count is not None: diff --git a/core/frontend/src/components/ChatPanel.tsx b/core/frontend/src/components/ChatPanel.tsx index 93b8eaa3..c0d10d51 100644 --- a/core/frontend/src/components/ChatPanel.tsx +++ b/core/frontend/src/components/ChatPanel.tsx @@ -9,6 +9,7 @@ import { Loader2, Paperclip, X, + Zap, } from "lucide-react"; import WorkerRunBubble from "@/components/WorkerRunBubble"; import type { WorkerRunGroup } from "@/components/WorkerRunBubble"; @@ -54,7 +55,8 @@ export interface ChatMessage { | "worker_input_request" | "run_divider" | "colony_link" - | "inherited_block"; + | "inherited_block" + | "trigger"; role?: "queen" | "worker"; /** Which worker thread this message belongs to (worker agent name) */ thread?: string; @@ -635,6 +637,53 @@ const MessageBubble = memo( ); } + if (msg.type === "trigger") { + // Rendered when a scheduler/webhook trigger fires. Content is a JSON + // payload: { trigger_id, trigger_type, name, task, last_fired_at, + // fire_count }. Shown as a distinctive banner marking the start of + // the turn the queen is about to run in response. + let parsed: { + trigger_id?: string; + trigger_type?: string; + name?: string; + task?: string; + fire_count?: number; + last_fired_at?: number; + } = {}; + try { + parsed = JSON.parse(msg.content); + } catch { + // Fall through to plain text + } + const label = parsed.name || parsed.trigger_id || "trigger"; + const kind = parsed.trigger_type || "timer"; + const task = (parsed.task || "").trim(); + const fireCount = parsed.fire_count; + return ( +
+
+
+ + + + + {kind === "webhook" ? "Webhook" : "Scheduler"} fired + + {label} + {fireCount != null && fireCount > 0 && ( + #{fireCount} + )} +
+ {task && ( +

+ {task} +

+ )} +
+
+ ); + } + if (msg.type === "colony_link") { // Rendered when the queen calls create_colony() and the backend // emits a COLONY_CREATED event. Gives the user a clickable card diff --git a/core/frontend/src/lib/chat-helpers.ts b/core/frontend/src/lib/chat-helpers.ts index d9ef1ecf..fae0fbae 100644 --- a/core/frontend/src/lib/chat-helpers.ts +++ b/core/frontend/src/lib/chat-helpers.ts @@ -211,6 +211,34 @@ export function sseEventToChatMessage( }; } + case "trigger_fired": { + // Surface each scheduler/webhook fire as a banner in the chat, so the + // user can see exactly when the queen was invoked by a trigger vs. by + // a typed message. The banner sits at the start of the turn the queen + // is about to run in response. + const triggerId = event.data?.trigger_id as string | undefined; + if (!triggerId) return null; + const payload = { + trigger_id: triggerId, + trigger_type: event.data?.trigger_type as string | undefined, + name: event.data?.name as string | undefined, + task: event.data?.task as string | undefined, + fire_count: event.data?.fire_count as number | undefined, + last_fired_at: event.data?.last_fired_at as number | undefined, + }; + return { + id: `trigger-${triggerId}-${payload.last_fired_at ?? event.timestamp}`, + agent: "Trigger", + agentColor: "", + content: JSON.stringify(payload), + timestamp: "", + type: "trigger", + thread, + createdAt, + streamId: event.stream_id || undefined, + }; + } + default: return null; } diff --git a/core/frontend/src/pages/colony-chat.tsx b/core/frontend/src/pages/colony-chat.tsx index 079494f3..41a63611 100644 --- a/core/frontend/src/pages/colony-chat.tsx +++ b/core/frontend/src/pages/colony-chat.tsx @@ -1190,6 +1190,29 @@ export default function ColonyChat() { ); updateGraphNodeStatus(nodeId, "complete"); setTimeout(() => updateGraphNodeStatus(nodeId, "running"), 1500); + + // Render a banner in the chat marking the start of the turn the + // queen is about to run in response. Matches the replay path in + // chat-helpers.ts (case "trigger_fired") so live + restore look + // identical. + const bannerPayload = { + trigger_id: triggerId, + trigger_type: event.data?.trigger_type as string | undefined, + name: event.data?.name as string | undefined, + task: event.data?.task as string | undefined, + fire_count: fireCount, + last_fired_at: lastFiredAt, + }; + upsertMessage({ + id: `trigger-${triggerId}-${lastFiredAt ?? event.timestamp}`, + agent: "Trigger", + agentColor: "", + content: JSON.stringify(bannerPayload), + timestamp: "", + type: "trigger", + thread: agentPath, + createdAt: lastFiredAt ?? Date.now(), + }); } break; }