Skip to content

Commit 586dc2d

Browse files
authored
Dashboard Agent: Watch (background condition watches + wake notifications + alerts) (#4456)
Stacked on #4418 — the diff against that branch is the complete Watch feature, extracted so the base agent PR can land without it.
1 parent 447223e commit 586dc2d

120 files changed

Lines changed: 22775 additions & 189 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Watches you set up with the dashboard agent can now alert you by email, Slack, or webhook when they fire. Pick the new "Dashboard agent watches" type on the Alerts page, and turn it off again from any alert email.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
There's now a **Watch…** button on runs, queues, errors and the health report. It opens a short form with the right thing to wait for already filled in — a run finishing, a queue clearing, an error coming back, an environment recovering — so one click is enough. Open **Customize** first if you'd rather change how long it waits, how often it checks, or what it waits for, and you can ask for an email as well as the chat message.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
A queue watch can now wait for three more things under **Customize**: the queue coming back below a number you pick, the queue stopping moving at all, and runs waiting longer than a limit you set. On a queue where runs are already waiting too long, the **Watch…** button opens on that wait instead of on "until it clears".
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Ask the dashboard agent to tell you when something happens — a run starting or finishing, a queue clearing or growing past a number you pick, an error coming back, an environment recovering — and it messages you in the chat once with the answer. It tells you either way: that the run finished, that it failed, or that the queue still hadn't cleared by the time it stopped looking. If the thing you asked about has already happened, it just says so instead of waiting. Each chat can wait on up to three things at a time, for up to 24 hours. You can also ask it to start looking into the cause if the news turns out to be bad, and it will — otherwise it just tells you and stops.

apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx

Lines changed: 125 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
2-
import { useCallback, useMemo, useState } from "react";
1+
import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33
import {
44
ResizableHandle,
55
ResizablePanel,
66
ResizablePanelGroup,
77
} from "~/components/primitives/Resizable";
8+
import { useEnvironment } from "~/hooks/useEnvironment";
9+
import { useOrganization } from "~/hooks/useOrganizations";
10+
import { useProject } from "~/hooks/useProject";
811
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
912
import { DashboardAgentPanel } from "./DashboardAgentPanel";
1013
import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher";
@@ -15,6 +18,16 @@ import {
1518
readAgentFullscreen,
1619
writeAgentFullscreen,
1720
} from "./panel-layout";
21+
import {
22+
showWatchWakesSummaryToast,
23+
showWatchWakeToast,
24+
WAKE_TOAST_MAX_INDIVIDUAL,
25+
type WatchWake,
26+
} from "./WatchWakeToast";
27+
28+
// How often the closed panel asks whether a watch woke a chat. A wake is worth
29+
// noticing within a minute, and the count is one indexed query.
30+
const UNREAD_POLL_INTERVAL_MS = 60_000;
1831

1932
/**
2033
* Mounts the dashboard agent in the env layout. Renders the page content
@@ -38,7 +51,18 @@ export function DashboardAgent({
3851
// The product-controlled promoted prompt chip, from the feature flag.
3952
promotedPrompt?: SuggestedPrompt;
4053
}) {
54+
const organization = useOrganization();
55+
const project = useProject();
56+
const environment = useEnvironment();
57+
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
58+
4159
const [open, setOpen] = useState(false);
60+
const [unreadWakes, setUnreadWakes] = useState(0);
61+
// Wakes already toasted this session. Session-scoped on purpose: a wake that
62+
// arrived overnight deserves the toast on the first poll after a reload, but a
63+
// wake the user has already been shown (and maybe dismissed) must not come
64+
// back every 60s while the chat stays unread.
65+
const toastedWakes = useRef(new Set<string>());
4266
// The side panel is the default; someone who last worked fullscreen gets
4367
// fullscreen back. Read lazily so SSR always renders the side panel.
4468
const [fullscreen, setFullscreen] = useState(readAgentFullscreen);
@@ -57,14 +81,39 @@ export function DashboardAgent({
5781
const [requestedMessage, setRequestedMessage] = useState<
5882
{ text: string; seq: number } | undefined
5983
>(undefined);
84+
// A specific chat to open, from a wake toast. `seq` so the same chat can be
85+
// asked for twice (a second wake in a chat the user has already left).
86+
const [openChatRequest, setOpenChatRequest] = useState<
87+
{ chatId: string; seq: number } | undefined
88+
>(undefined);
89+
// A watch card asked for by a `Watch…` entry (§2.1). A card is not a message,
90+
// so it travels on its own channel: the panel opens it pre-filled, and nothing
91+
// reaches the transcript unless the user submits it.
92+
const [watchRequest, setWatchRequest] = useState<{ spec: WatchSpec; seq: number } | undefined>(
93+
undefined
94+
);
6095

6196
// Closing drops any pending request, so reopening the panel later doesn't
6297
// replay text the user has moved on from.
6398
const setPanelOpen = useCallback((next: boolean) => {
6499
setOpen(next);
65-
// The panel unmounts on close, so a stale request would re-apply on the next
66-
// open instead of restoring the last chat.
67-
if (!next) setRequestedMessage(undefined);
100+
// Closing drops both pending requests: the panel unmounts, so a stale one
101+
// would re-apply on the next open instead of restoring the last chat.
102+
if (!next) {
103+
setRequestedMessage(undefined);
104+
setOpenChatRequest(undefined);
105+
// An abandoned card leaves no trace (§2.2) — including no pending request
106+
// that would re-open it the next time the panel is.
107+
setWatchRequest(undefined);
108+
}
109+
}, []);
110+
111+
// Open the panel on the chat a wake happened in. Without the chat id the panel
112+
// would just restore whatever it had open last, which is rarely the one the
113+
// toast is about.
114+
const openChat = useCallback((chatId: string) => {
115+
setOpen(true);
116+
setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
68117
}, []);
69118

70119
const openWith = useCallback((text: string) => {
@@ -74,6 +123,72 @@ export function DashboardAgent({
74123
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
75124
}, []);
76125

126+
const openWithWatch = useCallback((spec: WatchSpec) => {
127+
setOpen(true);
128+
setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 }));
129+
}, []);
130+
131+
// The dot's poll, and the toast's. Runs only while the panel is CLOSED — an
132+
// open panel shows the wake in the transcript, so polling then would only race
133+
// the read marker. Both the interval and the on-close refresh come from this
134+
// effect re-running on `open`.
135+
useEffect(() => {
136+
if (!hasAccess || open) return;
137+
138+
let cancelled = false;
139+
const load = async () => {
140+
try {
141+
const res = await fetch(`${actionPath}?unread=1`);
142+
if (!res.ok) return;
143+
const data = (await res.json()) as { unreadWakes?: number; wakes?: WatchWake[] };
144+
if (cancelled) return;
145+
setUnreadWakes(data.unreadWakes ?? 0);
146+
147+
const fresh = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId));
148+
for (const wake of fresh) toastedWakes.current.add(wake.watchId);
149+
150+
// A burst gets one summary toast: a stack of persistent toasts is a wall,
151+
// not a notification.
152+
if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) {
153+
showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true));
154+
} else {
155+
// Oldest first, so the newest wake ends up nearest the user.
156+
for (const wake of [...fresh].reverse()) {
157+
showWatchWakeToast(wake, openChat);
158+
}
159+
}
160+
} catch {
161+
// Offline or a hiccup — leave the dot as it is and try again next tick.
162+
}
163+
};
164+
165+
void load();
166+
const interval = window.setInterval(load, UNREAD_POLL_INTERVAL_MS);
167+
return () => {
168+
cancelled = true;
169+
window.clearInterval(interval);
170+
};
171+
}, [hasAccess, open, actionPath, setPanelOpen, openChat]);
172+
173+
// A chat the user is now looking at has no unread wakes. Zeroes the dot right
174+
// away (the poll restores the truth on close if another chat still has one) and
175+
// persists the read marker for the chat that's actually visible.
176+
const markChatRead = useCallback(
177+
async (chatId: string) => {
178+
setUnreadWakes(0);
179+
const body = new FormData();
180+
body.set("intent", "read");
181+
body.set("chatId", chatId);
182+
try {
183+
await fetch(actionPath, { method: "POST", body });
184+
} catch {
185+
// Not worth surfacing: the marker is caught up the next time the chat is
186+
// opened.
187+
}
188+
},
189+
[actionPath]
190+
);
191+
77192
// ⌘J is contextual: closed → open the panel (the composer focuses itself, so
78193
// the keystroke lands you in the text field); open → start a new chat.
79194
// Closing is Esc or the header's ×, never ⌘J.
@@ -96,8 +211,8 @@ export function DashboardAgent({
96211
useDashboardAgentOpenRequests({ enabled: hasAccess, openWith, setOpen: setPanelOpen });
97212

98213
const context = useMemo(
99-
() => ({ open, setOpen: setPanelOpen, openWith }),
100-
[open, setPanelOpen, openWith]
214+
() => ({ open, setOpen: setPanelOpen, openWith, openWithWatch, unreadWakes }),
215+
[open, setPanelOpen, openWith, openWithWatch, unreadWakes]
101216
);
102217

103218
if (!hasAccess) {
@@ -130,8 +245,11 @@ export function DashboardAgent({
130245
<DashboardAgentPanel
131246
onClose={() => setPanelOpen(false)}
132247
requestedMessage={requestedMessage}
248+
openChatRequest={openChatRequest}
249+
watchRequest={watchRequest}
133250
newChatSeq={newChatSeq}
134251
promotedPrompt={promotedPrompt}
252+
onChatRead={markChatRead}
135253
isFullscreen={fullscreen}
136254
onToggleFullscreen={toggleFullscreen}
137255
/>

apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useChat } from "@ai-sdk/react";
22
import type { UIMessage } from "@ai-sdk/react";
33
import type { dashboardAgent } from "@internal/dashboard-agent";
4-
import type { AgentIntent, SuggestedPrompt } from "@internal/dashboard-agent-contracts";
4+
import type { AgentIntent, SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts";
55
import { useNavigate } from "@remix-run/react";
66
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
77
import { useCallback, useEffect, useRef, useState } from "react";
@@ -16,6 +16,7 @@ import { appendRunFilters, pendingNavigateIntents } from "./navigate-target";
1616
import type { AgentPageContext } from "./page-context-types";
1717
import { useAgentMessageQuota } from "./useAgentMessageQuota";
1818
import { useTriggerUriResolver } from "./useTriggerUriResolver";
19+
import { WatchChips, type WatchChip } from "./WatchChips";
1920

2021
// The persisted session for a chat: the session-scoped token plus the stream
2122
// cursor. Resuming with `lastEventId` is what stops the agent's `.out` stream
@@ -59,7 +60,12 @@ export function DashboardAgentChat({
5960
streaming,
6061
prefill,
6162
promotedPrompt,
63+
watches,
6264
pagePaths,
65+
watchCard,
66+
appendedMessage,
67+
onWatchIntent,
68+
onCancelWatch,
6369
onTurnSettled,
6470
onActivityChange,
6571
}: {
@@ -87,9 +93,27 @@ export function DashboardAgentChat({
8793
// The product-controlled promoted chip, from the feature flag. Only used for
8894
// the suggested prompts on an empty chat.
8995
promotedPrompt?: SuggestedPrompt;
96+
// This chat's active watches, from the panel's history load.
97+
watches: WatchChip[];
9098
/** Host-resolved dashboard paths for settings-page footer actions. */
9199
pagePaths?: Record<string, string>;
92-
/** A turn settled — tell the panel to refresh its history list. */
100+
/** The ephemeral watch card, when one is open. Sits above the composer. */
101+
watchCard?: React.ReactNode;
102+
/**
103+
* A message the SERVER appended outside a turn — the watch card's confirmation
104+
* or one-shot result. It is already durable in the store; this puts it in the
105+
* live transcript now instead of on the next open. `seq` makes each append
106+
* distinct, so the effect applies it exactly once.
107+
*/
108+
appendedMessage?: { message: UIMessage; seq: number };
109+
/**
110+
* A card offered a watch. Every `watch` intent means the same thing — open the
111+
* configuration card pre-filled with this spec — so the user reviews and
112+
* submits it, and nothing is posted or persisted if they don't (§2.2).
113+
*/
114+
onWatchIntent?: (spec: WatchSpec) => void;
115+
onCancelWatch: (watchId: string) => void;
116+
/** A watch was created — tell the panel to re-read the chips. */
93117
onTurnSettled: () => void;
94118
/**
95119
* Whether a turn is in flight, for the History list's row marker. Only this
@@ -165,6 +189,7 @@ export function DashboardAgentChat({
165189

166190
const {
167191
messages: rawMessages,
192+
setMessages,
168193
sendMessage,
169194
status,
170195
stop: aiStop,
@@ -199,6 +224,20 @@ export function DashboardAgentChat({
199224
const activity: TurnActivity | null =
200225
status === "submitted" ? "thinking" : status === "streaming" ? "working" : null;
201226

227+
// A server-appended block (the watch card's outcome) joins the live transcript
228+
// in place. Applied once per `seq`: the append is already persisted, so
229+
// replaying it would show the same confirmation twice.
230+
const appendedSeq = useRef<number | undefined>(undefined);
231+
useEffect(() => {
232+
if (!appendedMessage || appendedSeq.current === appendedMessage.seq) return;
233+
appendedSeq.current = appendedMessage.seq;
234+
setMessages((current) =>
235+
current.some((message) => message.id === appendedMessage.message.id)
236+
? current
237+
: [...current, appendedMessage.message]
238+
);
239+
}, [appendedMessage, setMessages]);
240+
202241
// Cold start: trigger the first turn by sending the pending message once.
203242
const sentFirst = useRef(false);
204243
useEffect(() => {
@@ -263,8 +302,14 @@ export function DashboardAgentChat({
263302
);
264303

265304
// What a card's action does. An `ask` goes back into the conversation as the
266-
// user's own question, so the click is visible in the transcript rather than
267-
// happening silently.
305+
// user's own question.
306+
//
307+
// A `watch` does NOT: it opens the configuration card pre-filled with the spec
308+
// the card offered. Every watch intent is treated this way, whatever offered it
309+
// — so the user always sees what they are about to start, can change the window
310+
// or the condition first, and an offer they walk away from leaves no trace. It
311+
// used to post a visible "Watch this for me…" request and let the agent answer
312+
// with schedule_watch; the card replaces that turn with 0 LLM.
268313
//
269314
// `propose_fix` is reserved and must never be executed.
270315
const handleIntent = useCallback(
@@ -273,14 +318,17 @@ export function DashboardAgentChat({
273318
case "ask":
274319
submit(intent.prompt);
275320
return;
321+
case "watch":
322+
onWatchIntent?.(intent.spec);
323+
return;
276324
case "navigate":
277325
void goTo(intent);
278326
return;
279327
default:
280328
console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`);
281329
}
282330
},
283-
[submit, goTo]
331+
[submit, goTo, onWatchIntent]
284332
);
285333

286334
// The `navigate_to` tool answers with an intent and the agent then narrates it
@@ -324,6 +372,15 @@ export function DashboardAgentChat({
324372

325373
return (
326374
<>
375+
{/* What this chat is watching, at the top of the panel: a watch outcome
376+
arrives in the transcript unprompted, so the chips are what explain
377+
where those messages will come from. */}
378+
{/* Chips are an offer to cancel, so only live watches get one; the full
379+
list still flows to the messages for the wake banner's tone. */}
380+
<WatchChips
381+
watches={watches.filter((watch) => watch.status === "active")}
382+
onCancel={onCancelWatch}
383+
/>
327384
{/* A cold-start chat mounts with no messages and a first message about to
328385
be sent, so the prompts would flash for a frame before the transcript
329386
replaced them. Gate on that pending send. */}
@@ -344,9 +401,11 @@ export function DashboardAgentChat({
344401
onDismissError={clearError}
345402
onIntent={handleIntent}
346403
pagePaths={pagePaths}
404+
watches={watches}
347405
resolveUri={resolveUri}
348406
/>
349407
)}
408+
{watchCard}
350409
{/* The Free plan's message cap occupies the composer slot: at the cap the
351410
composer is replaced by the upgrade block (a composer you can't send
352411
from is worse than none), and under it the composer is followed by the

0 commit comments

Comments
 (0)