Skip to content

Commit 19d6625

Browse files
committed
refactor: extract the Watch feature to feat/dashboard-agent-watch
The base agent branch now ships without watches: schedule_watch, the tick loop, wake delivery, the expiry sweep, watch alerts (email template, alert type, unsubscribe), the watches table and its migrations, and every UI surface (chips, wake banner, toast, unread dot, watching status) are gone. The complete feature lives on feat/dashboard-agent-watch, stacked on this branch. The review stand (seeder, heartbeat, guidebook) stays here.
1 parent 840b798 commit 19d6625

100 files changed

Lines changed: 165 additions & 14651 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-watch-alerts.md

Lines changed: 0 additions & 6 deletions
This file was deleted.

.server-changes/dashboard-agent-watches.md

Lines changed: 0 additions & 6 deletions
This file was deleted.

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

Lines changed: 6 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,14 @@
11
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
2-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2+
import { useCallback, useMemo, 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";
118
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
129
import { DashboardAgentPanel } from "./DashboardAgentPanel";
1310
import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher";
1411
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;
2512

2613
/**
2714
* Mounts the dashboard agent in the env layout. Renders the page content
@@ -45,47 +32,20 @@ export function DashboardAgent({
4532
// The product-controlled promoted prompt chip, from the feature flag.
4633
promotedPrompt?: SuggestedPrompt;
4734
}) {
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-
5335
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>());
6036
// A request from `openWith`, handed to the panel. `seq` makes repeat requests
6137
// with the same text distinct, so the panel can tell them apart.
6238
const [requestedMessage, setRequestedMessage] = useState<
6339
{ text: string; seq: number } | undefined
6440
>(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);
7041

7142
// Closing drops any pending request, so reopening the panel later doesn't
7243
// replay text the user has moved on from.
7344
const setPanelOpen = useCallback((next: boolean) => {
7445
setOpen(next);
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 }));
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);
8949
}, []);
9050

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

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-
15958
// ⌘J toggles the panel. Opening mounts the composer, which focuses itself, so
16059
// the shortcut lands you in the text field. Enabled inside inputs too, so the
16160
// same keystroke closes the panel while you're typing in it.
@@ -172,8 +71,8 @@ export function DashboardAgent({
17271
useDashboardAgentOpenRequests({ enabled: hasAccess, openWith, setOpen: setPanelOpen });
17372

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

17978
if (!hasAccess) {
@@ -196,9 +95,7 @@ export function DashboardAgent({
19695
<DashboardAgentPanel
19796
onClose={() => setPanelOpen(false)}
19897
requestedMessage={requestedMessage}
199-
openChatRequest={openChatRequest}
20098
promotedPrompt={promotedPrompt}
201-
onChatRead={markChatRead}
20299
/>
203100
</ResizablePanel>
204101
</ResizablePanelGroup>

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

Lines changed: 4 additions & 48 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, WatchSpec } from "@internal/dashboard-agent-contracts";
4+
import type { AgentIntent, SuggestedPrompt } 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,29 +16,6 @@ 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-
}
4219

4320
// The persisted session for a chat: the session-scoped token plus the stream
4421
// cursor. Resuming with `lastEventId` is what stops the agent's `.out` stream
@@ -82,9 +59,7 @@ export function DashboardAgentChat({
8259
streaming,
8360
prefill,
8461
promotedPrompt,
85-
watches,
8662
pagePaths,
87-
onCancelWatch,
8863
onTurnSettled,
8964
onActivityChange,
9065
}: {
@@ -112,12 +87,9 @@ export function DashboardAgentChat({
11287
// The product-controlled promoted chip, from the feature flag. Only used for
11388
// the suggested prompts on an empty chat.
11489
promotedPrompt?: SuggestedPrompt;
115-
// This chat's active watches, from the panel's history load.
116-
watches: WatchChip[];
11790
/** Host-resolved dashboard paths for settings-page footer actions. */
11891
pagePaths?: Record<string, string>;
119-
onCancelWatch: (watchId: string) => void;
120-
/** A watch was created — tell the panel to re-read the chips. */
92+
/** A turn settled — tell the panel to refresh its history list. */
12193
onTurnSettled: () => void;
12294
/**
12395
* Whether a turn is in flight, for the History list's row marker. Only this
@@ -291,11 +263,8 @@ export function DashboardAgentChat({
291263
);
292264

293265
// What a card's action does. An `ask` goes back into the conversation as the
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.
266+
// user's own question, so the click is visible in the transcript rather than
267+
// happening silently.
299268
//
300269
// `propose_fix` is reserved and must never be executed.
301270
const handleIntent = useCallback(
@@ -304,9 +273,6 @@ export function DashboardAgentChat({
304273
case "ask":
305274
submit(intent.prompt);
306275
return;
307-
case "watch":
308-
submit(watchRequestText(intent.spec));
309-
return;
310276
case "navigate":
311277
void goTo(intent);
312278
return;
@@ -358,15 +324,6 @@ export function DashboardAgentChat({
358324

359325
return (
360326
<>
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-
/>
370327
{/* A cold-start chat mounts with no messages and a first message about to
371328
be sent, so the prompts would flash for a frame before the transcript
372329
replaced them. Gate on that pending send. */}
@@ -385,7 +342,6 @@ export function DashboardAgentChat({
385342
onDismissError={clearError}
386343
onIntent={handleIntent}
387344
pagePaths={pagePaths}
388-
watches={watches}
389345
resolveUri={resolveUri}
390346
/>
391347
)}

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

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,40 +7,31 @@ 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";
1110

1211
// Date fields arrive as strings over the loader's JSON.
1312
export type DashboardAgentChat = {
1413
id: string;
1514
title: string;
1615
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;
2316
/** The chat's latest investigation is still `in_progress`. */
2417
hasOpenInvestigation?: boolean;
2518
};
2619

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

3023
const PROCESS_LABELS: Record<ChatProcess, string> = {
3124
thinking: "Agent is thinking",
3225
investigating: "Investigation in progress",
33-
watching: "Watch active",
3426
};
3527

3628
/**
3729
* `thinking` outranks the rest: a turn in flight is the thing that's about to
38-
* change, an investigation or a watch just sits there.
30+
* change, an investigation just sits there.
3931
*/
4032
function chatProcess(chat: DashboardAgentChat, isThinking: boolean): ChatProcess | null {
4133
if (isThinking) return "thinking";
4234
if (chat.hasOpenInvestigation) return "investigating";
43-
if (chat.hasActiveWatch) return "watching";
4435
return null;
4536
}
4637

@@ -53,25 +44,12 @@ function ProcessIcon({ process }: { process: ChatProcess }) {
5344
{process === "investigating" ? (
5445
<MagnifyingGlassIcon className="size-3.5" />
5546
) : (
56-
// Thinking and watching both spin — "something is going on here"; the
57-
// hover title says which.
5847
<Spinner className="size-3.5" />
5948
)}
6049
</span>
6150
);
6251
}
6352

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-
7553
/** Units the row's age can be shown in. Months and years would read as "1.8mo" for
7654
* eight weeks, which is worse than "8w" — weeks are the coarsest useful unit. */
7755
const AGE_UNITS = ["w", "d", "h", "m"] as const;
@@ -92,8 +70,8 @@ export function chatAge(lastMessageAt: string, now: number = Date.now()): string
9270

9371
/**
9472
* The chat list, as the body of the header's title dropdown. Rows keep the
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.
73+
* panel's list language (process icon, hover delete) — only the container
74+
* changed from a full panel view to a popover menu.
9775
*/
9876
export function DashboardAgentHistoryMenu({
9977
chats,
@@ -126,14 +104,13 @@ export function DashboardAgentHistoryMenu({
126104
</Paragraph>
127105
) : (
128106
<AgentList>
129-
{unreadFirst(chats).map((chat) => {
107+
{chats.map((chat) => {
130108
const process = chatProcess(chat, chat.id === thinkingChatId);
131109
const age = chat.lastMessageAt ? chatAge(chat.lastMessageAt, now) : undefined;
132110
return (
133111
<AgentListRow
134112
key={chat.id}
135113
label={chat.title}
136-
unread={chat.hasUnreadWake ?? false}
137114
// null keeps the leading slot so every title starts at the
138115
// same x whether or not this chat has a status.
139116
status={process ? <ProcessIcon process={process} /> : null}

0 commit comments

Comments
 (0)