Skip to content

Commit bb036f9

Browse files
committed
feat(webapp): split Watch out of the dashboard agent's first PR
The agent ships Chat and Investigate here; Watch — telling the user later — follows in its own PR. The whole user-facing feature leaves: the watch card, chips, wake banner and toast, the unread-wake badge and its poll, the watch routes, checks, batches and sweeps, the watch alert email and its channel type, and the watchMaintenance cron. The agent no longer promises it either: schedule_watch and the alert tools are gone from the tool set, and the Watches section is out of the system prompt. Leaving that text in would have had the agent refuse to poll for something it could no longer offer. The datastore's watch tables stay. The migrations ship in this PR, so the drizzle schema that describes them has to ship too — a schema that no longer matched the migrated tables would make the next generate emit a drop.
1 parent f4337f7 commit bb036f9

142 files changed

Lines changed: 139 additions & 24147 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: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ 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-
1210
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.
1311

1412
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: 3 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts";
1+
import type { SuggestedPrompt } 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";
129
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
1310
import { DashboardAgentPanel } from "./DashboardAgentPanel";
1411
import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher";
@@ -19,68 +16,18 @@ import {
1916
readAgentFullscreen,
2017
writeAgentFullscreen,
2118
} 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;
3519

3620
/** `hasAccess` is a UI gate only; the resource routes enforce the same check server-side. */
3721
export function DashboardAgent({
3822
children,
3923
hasAccess = false,
4024
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,
4525
}: {
4626
children: React.ReactNode;
4727
hasAccess?: boolean;
4828
promotedPrompt?: SuggestedPrompt;
49-
initialUnreadWakes?: number;
50-
hasActiveWatches?: boolean;
5129
}) {
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-
5730
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);
8431
// Read lazily so SSR always renders the side panel.
8532
const [fullscreen, setFullscreen] = useState(readAgentFullscreen);
8633

@@ -106,130 +53,24 @@ export function DashboardAgent({
10653
const [requestedMessage, setRequestedMessage] = useState<
10754
{ text: string; seq: number } | undefined
10855
>(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-
);
11656

11757
const setPanelOpen = useCallback((next: boolean) => {
11858
setOpen(next);
11959
// Pending requests must be dropped or a stale one re-applies on the next open.
12060
if (!next) {
121-
visibleChat.current = null;
12261
setFullscreen(false);
12362
writeAgentFullscreen(false);
12463
setRequestedMessage(undefined);
125-
setOpenChatRequest(undefined);
126-
setWatchRequest(undefined);
12764
}
12865
}, []);
12966

130-
const openChat = useCallback((chatId: string) => {
131-
setOpen(true);
132-
setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
133-
}, []);
134-
13567
const openWith = useCallback((text: string) => {
13668
const trimmed = text.trim();
13769
if (!trimmed) return;
13870
setOpen(true);
13971
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
14072
}, []);
14173

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-
23374
// ⌘J is contextual: closed opens the panel, open starts a new chat. It never closes.
23475
useShortcutKeys({
23576
shortcut: TOGGLE_PANEL_SHORTCUT,
@@ -247,8 +88,8 @@ export function DashboardAgent({
24788
useDashboardAgentOpenRequests({ enabled: hasAccess, openWith, setOpen: setPanelOpen });
24889

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

25495
if (!hasAccess) {
@@ -277,11 +118,8 @@ export function DashboardAgent({
277118
<DashboardAgentPanel
278119
onClose={() => setPanelOpen(false)}
279120
requestedMessage={requestedMessage}
280-
openChatRequest={openChatRequest}
281-
watchRequest={watchRequest}
282121
newChatSeq={newChatSeq}
283122
promotedPrompt={promotedPrompt}
284-
onChatRead={markChatRead}
285123
isFullscreen={fullscreen}
286124
onToggleFullscreen={toggleFullscreen}
287125
/>

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

Lines changed: 4 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +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 {
5-
isWatchRequestMessageId,
6-
type AgentIntent,
7-
type SuggestedPrompt,
8-
type WatchSpec,
9-
} from "@internal/dashboard-agent-contracts";
4+
import type { AgentIntent, SuggestedPrompt } from "@internal/dashboard-agent-contracts";
105
import { useNavigate } from "@remix-run/react";
116
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
127
import { useCallback, useEffect, useRef, useState } from "react";
@@ -19,7 +14,7 @@ import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessa
1914
import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
2015
import { createTranscriptOrder, orderTranscript } from "./message-order";
2116
import { appendRunFilters } from "./navigate-target";
22-
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
17+
import { pendingNavigateIntents } from "./pending-intents";
2318
import type { AgentPageContext } from "./page-context-types";
2419
import {
2520
fetchChatTranscript,
@@ -28,7 +23,6 @@ import {
2823
} from "./settled-transcript";
2924
import { useAgentMessageQuota } from "./useAgentMessageQuota";
3025
import { useTriggerUriResolver } from "./useTriggerUriResolver";
31-
import { WatchChips, type WatchChip } from "./WatchChips";
3226

3327
// Resuming with `lastEventId` stops the `.out` stream replaying the previous turn.
3428
export type DashboardAgentSession = {
@@ -61,12 +55,7 @@ export function DashboardAgentChat({
6155
streaming,
6256
prefill,
6357
promotedPrompt,
64-
watches,
6558
pagePaths,
66-
watchCard,
67-
appendedMessages,
68-
onWatchIntent,
69-
onCancelWatch,
7059
onTurnSettled,
7160
onActivityChange,
7261
}: {
@@ -86,13 +75,7 @@ export function DashboardAgentChat({
8675
// `seq` makes each request distinct so the same text can be sent twice.
8776
prefill?: { text: string; seq: number };
8877
promotedPrompt?: SuggestedPrompt;
89-
watches: WatchChip[];
9078
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;
9679
onTurnSettled: () => void;
9780
onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
9881
}) {
@@ -189,20 +172,6 @@ export function DashboardAgentChat({
189172
const activity: TurnActivity | null =
190173
status === "submitted" ? "thinking" : status === "streaming" ? "working" : null;
191174

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-
206175
const sentFirst = useRef(false);
207176
useEffect(() => {
208177
if (pendingFirstMessage && !sentFirst.current) {
@@ -223,10 +192,7 @@ export function DashboardAgentChat({
223192
);
224193

225194
const retry = useCallback(() => {
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));
195+
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
230196
const text = lastUserMessage?.parts
231197
?.filter((p): p is { type: "text"; text: string } => p.type === "text")
232198
.map((p) => p.text)
@@ -264,17 +230,14 @@ export function DashboardAgentChat({
264230
case "ask":
265231
submit(intent.prompt);
266232
return;
267-
case "watch":
268-
onWatchIntent?.(intent.spec);
269-
return;
270233
case "navigate":
271234
void goTo(intent);
272235
return;
273236
default:
274237
console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`);
275238
}
276239
},
277-
[submit, goTo, onWatchIntent]
240+
[submit, goTo]
278241
);
279242

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

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-
303255
const stop = useCallback(() => {
304256
transport.stopGeneration(chatId);
305257
aiStop();
@@ -334,10 +286,6 @@ export function DashboardAgentChat({
334286

335287
return (
336288
<>
337-
<WatchChips
338-
watches={watches.filter((watch) => watch.status === "active")}
339-
onCancel={onCancelWatch}
340-
/>
341289
{messages.length === 0 && !pendingFirstMessage ? (
342290
<DashboardAgentHero
343291
onSelect={submit}
@@ -353,11 +301,9 @@ export function DashboardAgentChat({
353301
onDismissError={clearError}
354302
onIntent={handleIntent}
355303
pagePaths={pagePaths}
356-
watches={watches}
357304
resolveUri={resolveUri}
358305
/>
359306
)}
360-
{watchCard ? <div className="px-3 pb-2">{watchCard}</div> : null}
361307
{quota.kind === "reached" ? (
362308
<AgentUpgradeBlock
363309
limit={quota.limit}

0 commit comments

Comments
 (0)