Skip to content

Commit 89b7522

Browse files
committed
feat(webapp): tell the user later — the dashboard agent's Watch
"Tell me when this run finishes", "ping me if that error comes back". A watch checks on its own cadence, reports once, and stops within 24 hours. The user confirms a pre-filled card, so nothing starts behind their back; the answer lands in the chat, and by email if they asked for it. Restores the feature this branch's base PR set aside, unchanged: the card and chips, the wake banner and toast, the unread badge, the checks for runs, queues, errors and health, the batch scheduler and its backstops, the alert channel and its email, and the agent's schedule_watch tool with the prompt that governs it.
1 parent bb036f9 commit 89b7522

142 files changed

Lines changed: 24147 additions & 139 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.

.server-changes/dashboard-agent.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Meet the dashboard agent: a chat in every environment that answers questions abo
77

88
**Investigate** on a failed run, an error, a backed-up queue or a run that hasn't started gets you a worked-through answer — what happened, why, and how to fix it, with every claim linked to the runs, errors and deploys behind it.
99

10+
**Watch…** on a run, queue, error or the health report tells you when things change: a run finishes, a queue clears or grows past a number you pick, an error comes back, an environment recovers. The answer arrives in the chat and, if you want, by email, Slack or webhook — and the agent can look into bad news on its own. A watch reaches you on any browser you sign in from, without opening the chat first.
11+
1012
The health report reads the same everywhere — dashboard, terminal, editor. A very long chat keeps working: the agent summarises the earlier part and carries on. The agent's replies no longer show images.
1113

1214
A sample of conversations is scored automatically so the agent keeps getting better. Only the score and a one-line summary are kept, never your messages, data or code, and we can switch it off for your organization on request.

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

Lines changed: 165 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
1+
import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts";
22
import { useLocation } from "@remix-run/react";
33
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44
import {
55
ResizableHandle,
66
ResizablePanel,
77
ResizablePanelGroup,
88
} from "~/components/primitives/Resizable";
9+
import { useEnvironment } from "~/hooks/useEnvironment";
10+
import { useOrganization } from "~/hooks/useOrganizations";
11+
import { useProject } from "~/hooks/useProject";
912
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
1013
import { DashboardAgentPanel } from "./DashboardAgentPanel";
1114
import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher";
@@ -16,18 +19,68 @@ import {
1619
readAgentFullscreen,
1720
writeAgentFullscreen,
1821
} from "./panel-layout";
22+
import { startWakePolling } from "./wake-poll";
23+
import { shouldPollWakeFeed, subscribeWatchActivity } from "./watch-activity";
24+
import {
25+
showWatchWakesSummaryToast,
26+
showWatchWakeToast,
27+
WAKE_TOAST_MAX_INDIVIDUAL,
28+
type WatchWake,
29+
} from "./WatchWakeToast";
30+
31+
const TOASTED_WAKES_STORAGE_KEY = "tdev:dashboard-agent:toasted-wakes";
32+
33+
// Shorter than the poll interval, so a stuck request is dropped before the next tick.
34+
const UNREAD_REQUEST_TIMEOUT_MS = 30_000;
1935

2036
/** `hasAccess` is a UI gate only; the resource routes enforce the same check server-side. */
2137
export function DashboardAgent({
2238
children,
2339
hasAccess = false,
2440
promotedPrompt,
41+
/** From the page load: unread wakes waiting for this user, whatever this browser remembers. */
42+
initialUnreadWakes = 0,
43+
/** Also from the page load: a watch is running, so a wake can still arrive in this tab. */
44+
hasActiveWatches = false,
2545
}: {
2646
children: React.ReactNode;
2747
hasAccess?: boolean;
2848
promotedPrompt?: SuggestedPrompt;
49+
initialUnreadWakes?: number;
50+
hasActiveWatches?: boolean;
2951
}) {
52+
const organization = useOrganization();
53+
const project = useProject();
54+
const environment = useEnvironment();
55+
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
56+
3057
const [open, setOpen] = useState(false);
58+
// Seeded from the page load, so the launcher dot is right before the first poll answers.
59+
const [unreadWakes, setUnreadWakes] = useState(initialUnreadWakes);
60+
const toastedWakes = useRef(new Set<string>());
61+
// The toast source is recent deliveries, not unread, so the dedupe must survive a reload.
62+
useEffect(() => {
63+
try {
64+
const raw = window.localStorage.getItem(TOASTED_WAKES_STORAGE_KEY);
65+
if (raw) for (const id of JSON.parse(raw) as string[]) toastedWakes.current.add(id);
66+
} catch {
67+
// Storage unavailable; the in-memory dedupe still applies.
68+
}
69+
}, []);
70+
const rememberToasted = useCallback((watchId: string) => {
71+
toastedWakes.current.add(watchId);
72+
try {
73+
// Newest ids only, so the key can't grow unbounded.
74+
window.localStorage.setItem(
75+
TOASTED_WAKES_STORAGE_KEY,
76+
JSON.stringify([...toastedWakes.current].slice(-50))
77+
);
78+
} catch {
79+
// Same as the read.
80+
}
81+
}, []);
82+
// A wake in the on-screen chat toasts but must not light the dot.
83+
const visibleChat = useRef<string | null>(null);
3184
// Read lazily so SSR always renders the side panel.
3285
const [fullscreen, setFullscreen] = useState(readAgentFullscreen);
3386

@@ -53,24 +106,130 @@ export function DashboardAgent({
53106
const [requestedMessage, setRequestedMessage] = useState<
54107
{ text: string; seq: number } | undefined
55108
>(undefined);
109+
// `seq` so the same chat can be asked for twice.
110+
const [openChatRequest, setOpenChatRequest] = useState<
111+
{ chatId: string; seq: number } | undefined
112+
>(undefined);
113+
const [watchRequest, setWatchRequest] = useState<{ spec: WatchSpec; seq: number } | undefined>(
114+
undefined
115+
);
56116

57117
const setPanelOpen = useCallback((next: boolean) => {
58118
setOpen(next);
59119
// Pending requests must be dropped or a stale one re-applies on the next open.
60120
if (!next) {
121+
visibleChat.current = null;
61122
setFullscreen(false);
62123
writeAgentFullscreen(false);
63124
setRequestedMessage(undefined);
125+
setOpenChatRequest(undefined);
126+
setWatchRequest(undefined);
64127
}
65128
}, []);
66129

130+
const openChat = useCallback((chatId: string) => {
131+
setOpen(true);
132+
setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
133+
}, []);
134+
67135
const openWith = useCallback((text: string) => {
68136
const trimmed = text.trim();
69137
if (!trimmed) return;
70138
setOpen(true);
71139
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
72140
}, []);
73141

142+
const openWithWatch = useCallback((spec: WatchSpec) => {
143+
setOpen(true);
144+
setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 }));
145+
}, []);
146+
147+
// Nothing to be woken about means nothing to poll for. The page load's unread count and
148+
// active-watch flag are the ungated signals; the browser's own memory of a watch starts the
149+
// poll without a reload. Once any says yes this tab keeps polling, so a wake reaches a tab
150+
// that was open before the watch existed.
151+
const [watching, setWatching] = useState(false);
152+
useEffect(() => {
153+
const sync = () => {
154+
if (
155+
shouldPollWakeFeed({
156+
serverUnreadWakes: initialUnreadWakes,
157+
serverHasActiveWatches: hasActiveWatches,
158+
organizationId: organization.id,
159+
})
160+
)
161+
setWatching(true);
162+
};
163+
sync();
164+
return subscribeWatchActivity(sync);
165+
}, [organization.id, initialUnreadWakes, hasActiveWatches]);
166+
167+
useEffect(() => {
168+
if (!hasAccess || !watching) return;
169+
170+
let cancelled = false;
171+
const load = async () => {
172+
try {
173+
// Bounded, so one stuck request can't hold the poll's in-flight guard.
174+
const res = await fetch(`${actionPath}?unread=1`, {
175+
signal: AbortSignal.timeout(UNREAD_REQUEST_TIMEOUT_MS),
176+
});
177+
if (!res.ok) return;
178+
const data = (await res.json()) as { unreadWakes?: number; wakes?: WatchWake[] };
179+
if (cancelled) return;
180+
// The wakes list carries read ones too, so only unread ones are subtracted.
181+
const unreadInView = (data.wakes ?? []).filter(
182+
(wake) => wake.unread && wake.chatId === visibleChat.current
183+
).length;
184+
setUnreadWakes(Math.max(0, (data.unreadWakes ?? 0) - unreadInView));
185+
186+
const fresh = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId));
187+
for (const wake of fresh) rememberToasted(wake.watchId);
188+
189+
if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) {
190+
showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true));
191+
} else {
192+
for (const wake of [...fresh].reverse()) {
193+
showWatchWakeToast(wake, openChat);
194+
}
195+
}
196+
} catch {
197+
// Try again next tick.
198+
}
199+
};
200+
201+
const stop = startWakePolling({
202+
load,
203+
isHidden: () => document.hidden,
204+
onVisibilityChange: (listener) => {
205+
document.addEventListener("visibilitychange", listener);
206+
return () => document.removeEventListener("visibilitychange", listener);
207+
},
208+
});
209+
210+
return () => {
211+
cancelled = true;
212+
stop();
213+
};
214+
}, [hasAccess, watching, actionPath, setPanelOpen, openChat]);
215+
216+
// Zeroes the dot right away; the poll restores the truth if another chat has one.
217+
const markChatRead = useCallback(
218+
async (chatId: string) => {
219+
visibleChat.current = chatId;
220+
setUnreadWakes(0);
221+
const body = new FormData();
222+
body.set("intent", "read");
223+
body.set("chatId", chatId);
224+
try {
225+
await fetch(actionPath, { method: "POST", body });
226+
} catch {
227+
// Catches up on the next open.
228+
}
229+
},
230+
[actionPath]
231+
);
232+
74233
// ⌘J is contextual: closed opens the panel, open starts a new chat. It never closes.
75234
useShortcutKeys({
76235
shortcut: TOGGLE_PANEL_SHORTCUT,
@@ -88,8 +247,8 @@ export function DashboardAgent({
88247
useDashboardAgentOpenRequests({ enabled: hasAccess, openWith, setOpen: setPanelOpen });
89248

90249
const context = useMemo(
91-
() => ({ open, setOpen: setPanelOpen, openWith }),
92-
[open, setPanelOpen, openWith]
250+
() => ({ open, setOpen: setPanelOpen, openWith, openWithWatch, unreadWakes }),
251+
[open, setPanelOpen, openWith, openWithWatch, unreadWakes]
93252
);
94253

95254
if (!hasAccess) {
@@ -118,8 +277,11 @@ export function DashboardAgent({
118277
<DashboardAgentPanel
119278
onClose={() => setPanelOpen(false)}
120279
requestedMessage={requestedMessage}
280+
openChatRequest={openChatRequest}
281+
watchRequest={watchRequest}
121282
newChatSeq={newChatSeq}
122283
promotedPrompt={promotedPrompt}
284+
onChatRead={markChatRead}
123285
isFullscreen={fullscreen}
124286
onToggleFullscreen={toggleFullscreen}
125287
/>

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

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
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 {
5+
isWatchRequestMessageId,
6+
type AgentIntent,
7+
type SuggestedPrompt,
8+
type WatchSpec,
9+
} from "@internal/dashboard-agent-contracts";
510
import { useNavigate } from "@remix-run/react";
611
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
712
import { useCallback, useEffect, useRef, useState } from "react";
@@ -14,7 +19,7 @@ import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessa
1419
import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
1520
import { createTranscriptOrder, orderTranscript } from "./message-order";
1621
import { appendRunFilters } from "./navigate-target";
17-
import { pendingNavigateIntents } from "./pending-intents";
22+
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
1823
import type { AgentPageContext } from "./page-context-types";
1924
import {
2025
fetchChatTranscript,
@@ -23,6 +28,7 @@ import {
2328
} from "./settled-transcript";
2429
import { useAgentMessageQuota } from "./useAgentMessageQuota";
2530
import { useTriggerUriResolver } from "./useTriggerUriResolver";
31+
import { WatchChips, type WatchChip } from "./WatchChips";
2632

2733
// Resuming with `lastEventId` stops the `.out` stream replaying the previous turn.
2834
export type DashboardAgentSession = {
@@ -55,7 +61,12 @@ export function DashboardAgentChat({
5561
streaming,
5662
prefill,
5763
promotedPrompt,
64+
watches,
5865
pagePaths,
66+
watchCard,
67+
appendedMessages,
68+
onWatchIntent,
69+
onCancelWatch,
5970
onTurnSettled,
6071
onActivityChange,
6172
}: {
@@ -75,7 +86,13 @@ export function DashboardAgentChat({
7586
// `seq` makes each request distinct so the same text can be sent twice.
7687
prefill?: { text: string; seq: number };
7788
promotedPrompt?: SuggestedPrompt;
89+
watches: WatchChip[];
7890
pagePaths?: Record<string, string>;
91+
watchCard?: React.ReactNode;
92+
appendedMessages?: { messages: UIMessage[]; seq: number };
93+
/** Nothing is persisted until the user submits the card. */
94+
onWatchIntent?: (spec: WatchSpec) => void;
95+
onCancelWatch: (watchId: string) => void;
7996
onTurnSettled: () => void;
8097
onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
8198
}) {
@@ -172,6 +189,20 @@ export function DashboardAgentChat({
172189
const activity: TurnActivity | null =
173190
status === "submitted" ? "thinking" : status === "streaming" ? "working" : null;
174191

192+
// Once per `seq`: the append is already persisted, so a replay would duplicate it.
193+
// Ids are stable, so anything already in the transcript is skipped.
194+
const appendedSeq = useRef<number | undefined>(undefined);
195+
useEffect(() => {
196+
if (!appendedMessages || appendedSeq.current === appendedMessages.seq) return;
197+
appendedSeq.current = appendedMessages.seq;
198+
setMessages((current) => {
199+
const missing = appendedMessages.messages.filter(
200+
(message) => !current.some((existing) => existing.id === message.id)
201+
);
202+
return missing.length === 0 ? current : [...current, ...missing];
203+
});
204+
}, [appendedMessages, setMessages]);
205+
175206
const sentFirst = useRef(false);
176207
useEffect(() => {
177208
if (pendingFirstMessage && !sentFirst.current) {
@@ -192,7 +223,10 @@ export function DashboardAgentChat({
192223
);
193224

194225
const retry = useCallback(() => {
195-
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
226+
// A watch's consent record is a user message nobody typed, so retry skips it.
227+
const lastUserMessage = [...messages]
228+
.reverse()
229+
.find((m) => m.role === "user" && !isWatchRequestMessageId(m.id));
196230
const text = lastUserMessage?.parts
197231
?.filter((p): p is { type: "text"; text: string } => p.type === "text")
198232
.map((p) => p.text)
@@ -230,14 +264,17 @@ export function DashboardAgentChat({
230264
case "ask":
231265
submit(intent.prompt);
232266
return;
267+
case "watch":
268+
onWatchIntent?.(intent.spec);
269+
return;
233270
case "navigate":
234271
void goTo(intent);
235272
return;
236273
default:
237274
console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`);
238275
}
239276
},
240-
[submit, goTo]
277+
[submit, goTo, onWatchIntent]
241278
);
242279

243280
// Seeded from the loaded transcript before first render, so history never re-navigates.
@@ -252,6 +289,17 @@ export function DashboardAgentChat({
252289
if (target) void goTo(target);
253290
}, [messages, goTo]);
254291

292+
const watchProposedRef = useRef<Set<string> | null>(null);
293+
if (watchProposedRef.current === null) {
294+
watchProposedRef.current = new Set();
295+
pendingWatchIntents(initialMessages, watchProposedRef.current);
296+
}
297+
useEffect(() => {
298+
const pending = pendingWatchIntents(messages, watchProposedRef.current!);
299+
const proposed = pending.at(-1);
300+
if (proposed) onWatchIntent?.(proposed.spec);
301+
}, [messages, onWatchIntent]);
302+
255303
const stop = useCallback(() => {
256304
transport.stopGeneration(chatId);
257305
aiStop();
@@ -286,6 +334,10 @@ export function DashboardAgentChat({
286334

287335
return (
288336
<>
337+
<WatchChips
338+
watches={watches.filter((watch) => watch.status === "active")}
339+
onCancel={onCancelWatch}
340+
/>
289341
{messages.length === 0 && !pendingFirstMessage ? (
290342
<DashboardAgentHero
291343
onSelect={submit}
@@ -301,9 +353,11 @@ export function DashboardAgentChat({
301353
onDismissError={clearError}
302354
onIntent={handleIntent}
303355
pagePaths={pagePaths}
356+
watches={watches}
304357
resolveUri={resolveUri}
305358
/>
306359
)}
360+
{watchCard ? <div className="px-3 pb-2">{watchCard}</div> : null}
307361
{quota.kind === "reached" ? (
308362
<AgentUpgradeBlock
309363
limit={quota.limit}

0 commit comments

Comments
 (0)