Skip to content

Commit 28dd3ac

Browse files
committed
feat: the Watch feature, re-applied on top of the extracted base
Revert of the extraction commit (19d6625): restores schedule_watch, the tick loop, wake delivery with fenced claims, the expiry sweep, watch alerts, the watches table and migrations, and all UI surfaces.
1 parent 19d6625 commit 28dd3ac

100 files changed

Lines changed: 14651 additions & 165 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+
Ask the dashboard agent to tell you when something happens — a run starting or finishing, a backlog clearing, an error coming back, an environment recovering — and it messages you in the chat once it does. Each chat can wait on up to three things at a time, for up to 24 hours.

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

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
2-
import { useCallback, useMemo, useState } from "react";
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";
1114
import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest";
15+
import {
16+
showWatchWakesSummaryToast,
17+
showWatchWakeToast,
18+
WAKE_TOAST_MAX_INDIVIDUAL,
19+
type WatchWake,
20+
} from "./WatchWakeToast";
21+
22+
// How often the closed panel asks whether a watch woke a chat. A wake is worth
23+
// noticing within a minute, and the count is one indexed query.
24+
const UNREAD_POLL_INTERVAL_MS = 60_000;
1225

1326
/**
1427
* Mounts the dashboard agent in the env layout. Renders the page content
@@ -32,20 +45,47 @@ export function DashboardAgent({
3245
// The product-controlled promoted prompt chip, from the feature flag.
3346
promotedPrompt?: SuggestedPrompt;
3447
}) {
48+
const organization = useOrganization();
49+
const project = useProject();
50+
const environment = useEnvironment();
51+
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
52+
3553
const [open, setOpen] = useState(false);
54+
const [unreadWakes, setUnreadWakes] = useState(0);
55+
// Wakes already toasted this session. Session-scoped on purpose: a wake that
56+
// arrived overnight deserves the toast on the first poll after a reload, but a
57+
// wake the user has already been shown (and maybe dismissed) must not come
58+
// back every 60s while the chat stays unread.
59+
const toastedWakes = useRef(new Set<string>());
3660
// A request from `openWith`, handed to the panel. `seq` makes repeat requests
3761
// with the same text distinct, so the panel can tell them apart.
3862
const [requestedMessage, setRequestedMessage] = useState<
3963
{ text: string; seq: number } | undefined
4064
>(undefined);
65+
// A specific chat to open, from a wake toast. `seq` so the same chat can be
66+
// asked for twice (a second wake in a chat the user has already left).
67+
const [openChatRequest, setOpenChatRequest] = useState<
68+
{ chatId: string; seq: number } | undefined
69+
>(undefined);
4170

4271
// Closing drops any pending request, so reopening the panel later doesn't
4372
// replay text the user has moved on from.
4473
const setPanelOpen = useCallback((next: boolean) => {
4574
setOpen(next);
46-
// The panel unmounts on close, so a stale request would re-apply on the next
47-
// open instead of restoring the last chat.
48-
if (!next) setRequestedMessage(undefined);
75+
// Closing drops both pending requests: the panel unmounts, so a stale one
76+
// would re-apply on the next open instead of restoring the last chat.
77+
if (!next) {
78+
setRequestedMessage(undefined);
79+
setOpenChatRequest(undefined);
80+
}
81+
}, []);
82+
83+
// Open the panel on the chat a wake happened in. Without the chat id the panel
84+
// would just restore whatever it had open last, which is rarely the one the
85+
// toast is about.
86+
const openChat = useCallback((chatId: string) => {
87+
setOpen(true);
88+
setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
4989
}, []);
5090

5191
const openWith = useCallback((text: string) => {
@@ -55,6 +95,67 @@ export function DashboardAgent({
5595
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
5696
}, []);
5797

98+
// The dot's poll, and the toast's. Runs only while the panel is CLOSED — an
99+
// open panel shows the wake in the transcript, so polling then would only race
100+
// the read marker. Both the interval and the on-close refresh come from this
101+
// effect re-running on `open`.
102+
useEffect(() => {
103+
if (!hasAccess || open) return;
104+
105+
let cancelled = false;
106+
const load = async () => {
107+
try {
108+
const res = await fetch(`${actionPath}?unread=1`);
109+
if (!res.ok) return;
110+
const data = (await res.json()) as { unreadWakes?: number; wakes?: WatchWake[] };
111+
if (cancelled) return;
112+
setUnreadWakes(data.unreadWakes ?? 0);
113+
114+
const fresh = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId));
115+
for (const wake of fresh) toastedWakes.current.add(wake.watchId);
116+
117+
// A burst gets one summary toast: a stack of persistent toasts is a wall,
118+
// not a notification.
119+
if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) {
120+
showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true));
121+
} else {
122+
// Oldest first, so the newest wake ends up nearest the user.
123+
for (const wake of [...fresh].reverse()) {
124+
showWatchWakeToast(wake, openChat);
125+
}
126+
}
127+
} catch {
128+
// Offline or a hiccup — leave the dot as it is and try again next tick.
129+
}
130+
};
131+
132+
void load();
133+
const interval = window.setInterval(load, UNREAD_POLL_INTERVAL_MS);
134+
return () => {
135+
cancelled = true;
136+
window.clearInterval(interval);
137+
};
138+
}, [hasAccess, open, actionPath, setPanelOpen, openChat]);
139+
140+
// A chat the user is now looking at has no unread wakes. Zeroes the dot right
141+
// away (the poll restores the truth on close if another chat still has one) and
142+
// persists the read marker for the chat that's actually visible.
143+
const markChatRead = useCallback(
144+
async (chatId: string) => {
145+
setUnreadWakes(0);
146+
const body = new FormData();
147+
body.set("intent", "read");
148+
body.set("chatId", chatId);
149+
try {
150+
await fetch(actionPath, { method: "POST", body });
151+
} catch {
152+
// Not worth surfacing: the marker is caught up the next time the chat is
153+
// opened.
154+
}
155+
},
156+
[actionPath]
157+
);
158+
58159
// ⌘J toggles the panel. Opening mounts the composer, which focuses itself, so
59160
// the shortcut lands you in the text field. Enabled inside inputs too, so the
60161
// same keystroke closes the panel while you're typing in it.
@@ -71,8 +172,8 @@ export function DashboardAgent({
71172
useDashboardAgentOpenRequests({ enabled: hasAccess, openWith, setOpen: setPanelOpen });
72173

73174
const context = useMemo(
74-
() => ({ open, setOpen: setPanelOpen, openWith }),
75-
[open, setPanelOpen, openWith]
175+
() => ({ open, setOpen: setPanelOpen, openWith, unreadWakes }),
176+
[open, setPanelOpen, openWith, unreadWakes]
76177
);
77178

78179
if (!hasAccess) {
@@ -95,7 +196,9 @@ export function DashboardAgent({
95196
<DashboardAgentPanel
96197
onClose={() => setPanelOpen(false)}
97198
requestedMessage={requestedMessage}
199+
openChatRequest={openChatRequest}
98200
promotedPrompt={promotedPrompt}
201+
onChatRead={markChatRead}
99202
/>
100203
</ResizablePanel>
101204
</ResizablePanelGroup>

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

Lines changed: 48 additions & 4 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,29 @@ 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";
20+
21+
/**
22+
* The message a card's watch button sends on the user's behalf. Written the way
23+
* the user would ask, so the transcript reads as a request the agent then
24+
* confirms (via schedule_watch), not as UI state that changed silently.
25+
*/
26+
function watchRequestText(spec: WatchSpec): string {
27+
const note = "note" in spec && spec.note ? spec.note.trim() : "";
28+
if (note) return `Watch this for me — tell me when ${note}.`;
29+
switch (spec.kind) {
30+
case "backlog_drain":
31+
return `Watch this for me — tell me when the ${spec.queue} backlog drains.`;
32+
case "run_start":
33+
return `Watch this for me — tell me when run ${spec.runId} starts.`;
34+
case "run_finished":
35+
return `Watch this for me — tell me when run ${spec.runId} finishes.`;
36+
case "error_recurrence":
37+
return `Watch this for me — ping me if error ${spec.fingerprint} comes back.`;
38+
case "health_recovery":
39+
return "Watch this for me — tell me when health is back to normal.";
40+
}
41+
}
1942

2043
// The persisted session for a chat: the session-scoped token plus the stream
2144
// cursor. Resuming with `lastEventId` is what stops the agent's `.out` stream
@@ -59,7 +82,9 @@ export function DashboardAgentChat({
5982
streaming,
6083
prefill,
6184
promotedPrompt,
85+
watches,
6286
pagePaths,
87+
onCancelWatch,
6388
onTurnSettled,
6489
onActivityChange,
6590
}: {
@@ -87,9 +112,12 @@ export function DashboardAgentChat({
87112
// The product-controlled promoted chip, from the feature flag. Only used for
88113
// the suggested prompts on an empty chat.
89114
promotedPrompt?: SuggestedPrompt;
115+
// This chat's active watches, from the panel's history load.
116+
watches: WatchChip[];
90117
/** Host-resolved dashboard paths for settings-page footer actions. */
91118
pagePaths?: Record<string, string>;
92-
/** A turn settled — tell the panel to refresh its history list. */
119+
onCancelWatch: (watchId: string) => void;
120+
/** A watch was created — tell the panel to re-read the chips. */
93121
onTurnSettled: () => void;
94122
/**
95123
* Whether a turn is in flight, for the History list's row marker. Only this
@@ -263,8 +291,11 @@ export function DashboardAgentChat({
263291
);
264292

265293
// 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.
294+
// user's own question — and so does a `watch`: the click becomes a visible
295+
// request ("Watch this for me…") and the agent answers it with schedule_watch,
296+
// confirming in its own words and offering an email alert when none is set up.
297+
// A silent POST would be cheaper, but a watch the transcript never mentions
298+
// reads as nothing having happened.
268299
//
269300
// `propose_fix` is reserved and must never be executed.
270301
const handleIntent = useCallback(
@@ -273,6 +304,9 @@ export function DashboardAgentChat({
273304
case "ask":
274305
submit(intent.prompt);
275306
return;
307+
case "watch":
308+
submit(watchRequestText(intent.spec));
309+
return;
276310
case "navigate":
277311
void goTo(intent);
278312
return;
@@ -324,6 +358,15 @@ export function DashboardAgentChat({
324358

325359
return (
326360
<>
361+
{/* What this chat is watching, at the top of the panel: a watch outcome
362+
arrives in the transcript unprompted, so the chips are what explain
363+
where those messages will come from. */}
364+
{/* Chips are an offer to cancel, so only live watches get one; the full
365+
list still flows to the messages for the wake banner's tone. */}
366+
<WatchChips
367+
watches={watches.filter((watch) => watch.status === "active")}
368+
onCancel={onCancelWatch}
369+
/>
327370
{/* A cold-start chat mounts with no messages and a first message about to
328371
be sent, so the prompts would flash for a frame before the transcript
329372
replaced them. Gate on that pending send. */}
@@ -342,6 +385,7 @@ export function DashboardAgentChat({
342385
onDismissError={clearError}
343386
onIntent={handleIntent}
344387
pagePaths={pagePaths}
388+
watches={watches}
345389
resolveUri={resolveUri}
346390
/>
347391
)}

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,40 @@ import { FormButtons } from "~/components/primitives/FormButtons";
77
import { Paragraph } from "~/components/primitives/Paragraph";
88
import { Spinner } from "~/components/primitives/Spinner";
99
import { AgentList, AgentListRow, AgentListRowAction } from "./list-row";
10+
import type { WatchChip } from "./WatchChips";
1011

1112
// Date fields arrive as strings over the loader's JSON.
1213
export type DashboardAgentChat = {
1314
id: string;
1415
title: string;
1516
lastMessageAt: string | null;
17+
/** The chat's active watches, for the panel's chip row. */
18+
watches?: WatchChip[];
19+
/** A watch resolved in this chat and the user hasn't opened it since. */
20+
hasUnreadWake?: boolean;
21+
/** The chat holds at least one active watch. */
22+
hasActiveWatch?: boolean;
1623
/** The chat's latest investigation is still `in_progress`. */
1724
hasOpenInvestigation?: boolean;
1825
};
1926

2027
/** Something is running in this chat. One per row, most immediate first. */
21-
type ChatProcess = "thinking" | "investigating";
28+
type ChatProcess = "thinking" | "investigating" | "watching";
2229

2330
const PROCESS_LABELS: Record<ChatProcess, string> = {
2431
thinking: "Agent is thinking",
2532
investigating: "Investigation in progress",
33+
watching: "Watch active",
2634
};
2735

2836
/**
2937
* `thinking` outranks the rest: a turn in flight is the thing that's about to
30-
* change, an investigation just sits there.
38+
* change, an investigation or a watch just sits there.
3139
*/
3240
function chatProcess(chat: DashboardAgentChat, isThinking: boolean): ChatProcess | null {
3341
if (isThinking) return "thinking";
3442
if (chat.hasOpenInvestigation) return "investigating";
43+
if (chat.hasActiveWatch) return "watching";
3544
return null;
3645
}
3746

@@ -44,12 +53,25 @@ function ProcessIcon({ process }: { process: ChatProcess }) {
4453
{process === "investigating" ? (
4554
<MagnifyingGlassIcon className="size-3.5" />
4655
) : (
56+
// Thinking and watching both spin — "something is going on here"; the
57+
// hover title says which.
4758
<Spinner className="size-3.5" />
4859
)}
4960
</span>
5061
);
5162
}
5263

64+
/**
65+
* Chats with an unread wake go to the top — a watch that fired is the reason to
66+
* open the panel at all. Everything else keeps the server's order (pinned first,
67+
* then most recent), so this is a stable sort on one key.
68+
*/
69+
function unreadFirst(chats: DashboardAgentChat[]): DashboardAgentChat[] {
70+
return [...chats].sort(
71+
(a, b) => Number(b.hasUnreadWake ?? false) - Number(a.hasUnreadWake ?? false)
72+
);
73+
}
74+
5375
/** Units the row's age can be shown in. Months and years would read as "1.8mo" for
5476
* eight weeks, which is worse than "8w" — weeks are the coarsest useful unit. */
5577
const AGE_UNITS = ["w", "d", "h", "m"] as const;
@@ -70,8 +92,8 @@ export function chatAge(lastMessageAt: string, now: number = Date.now()): string
7092

7193
/**
7294
* The chat list, as the body of the header's title dropdown. Rows keep the
73-
* panel's list language (process icon, hover delete) — only the container
74-
* changed from a full panel view to a popover menu.
95+
* panel's list language (unread dot, process icon, hover delete) — only the
96+
* container changed from a full panel view to a popover menu.
7597
*/
7698
export function DashboardAgentHistoryMenu({
7799
chats,
@@ -104,13 +126,14 @@ export function DashboardAgentHistoryMenu({
104126
</Paragraph>
105127
) : (
106128
<AgentList>
107-
{chats.map((chat) => {
129+
{unreadFirst(chats).map((chat) => {
108130
const process = chatProcess(chat, chat.id === thinkingChatId);
109131
const age = chat.lastMessageAt ? chatAge(chat.lastMessageAt, now) : undefined;
110132
return (
111133
<AgentListRow
112134
key={chat.id}
113135
label={chat.title}
136+
unread={chat.hasUnreadWake ?? false}
114137
// null keeps the leading slot so every title starts at the
115138
// same x whether or not this chat has a status.
116139
status={process ? <ProcessIcon process={process} /> : null}

0 commit comments

Comments
 (0)