Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/dashboard-agent-watch-alerts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

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.
6 changes: 6 additions & 0 deletions .server-changes/dashboard-agent-watch-card.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

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.
6 changes: 6 additions & 0 deletions .server-changes/dashboard-agent-watches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

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.
132 changes: 125 additions & 7 deletions apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import { useCallback, useMemo, useState } from "react";
import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { DashboardAgentPanel } from "./DashboardAgentPanel";
import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher";
import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest";
import {
showWatchWakesSummaryToast,
showWatchWakeToast,
WAKE_TOAST_MAX_INDIVIDUAL,
type WatchWake,
} from "./WatchWakeToast";

// How often the closed panel asks whether a watch woke a chat. A wake is worth
// noticing within a minute, and the count is one indexed query.
const UNREAD_POLL_INTERVAL_MS = 60_000;

/**
* Mounts the dashboard agent in the env layout. Renders the page content
Expand All @@ -32,20 +45,56 @@ export function DashboardAgent({
// The product-controlled promoted prompt chip, from the feature flag.
promotedPrompt?: SuggestedPrompt;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;

const [open, setOpen] = useState(false);
const [unreadWakes, setUnreadWakes] = useState(0);
// Wakes already toasted this session. Session-scoped on purpose: a wake that
// arrived overnight deserves the toast on the first poll after a reload, but a
// wake the user has already been shown (and maybe dismissed) must not come
// back every 60s while the chat stays unread.
const toastedWakes = useRef(new Set<string>());
// A request from `openWith`, handed to the panel. `seq` makes repeat requests
// with the same text distinct, so the panel can tell them apart.
const [requestedMessage, setRequestedMessage] = useState<
{ text: string; seq: number } | undefined
>(undefined);
// A specific chat to open, from a wake toast. `seq` so the same chat can be
// asked for twice (a second wake in a chat the user has already left).
const [openChatRequest, setOpenChatRequest] = useState<
{ chatId: string; seq: number } | undefined
>(undefined);
// A watch card asked for by a `Watch…` entry (§2.1). A card is not a message,
// so it travels on its own channel: the panel opens it pre-filled, and nothing
// reaches the transcript unless the user submits it.
const [watchRequest, setWatchRequest] = useState<{ spec: WatchSpec; seq: number } | undefined>(
undefined
);

// Closing drops any pending request, so reopening the panel later doesn't
// replay text the user has moved on from.
const setPanelOpen = useCallback((next: boolean) => {
setOpen(next);
// The panel unmounts on close, so a stale request would re-apply on the next
// open instead of restoring the last chat.
if (!next) setRequestedMessage(undefined);
// Closing drops both pending requests: the panel unmounts, so a stale one
// would re-apply on the next open instead of restoring the last chat.
if (!next) {
setRequestedMessage(undefined);
setOpenChatRequest(undefined);
// An abandoned card leaves no trace (§2.2) — including no pending request
// that would re-open it the next time the panel is.
setWatchRequest(undefined);
}
}, []);

// Open the panel on the chat a wake happened in. Without the chat id the panel
// would just restore whatever it had open last, which is rarely the one the
// toast is about.
const openChat = useCallback((chatId: string) => {
setOpen(true);
setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
}, []);

const openWith = useCallback((text: string) => {
Expand All @@ -55,6 +104,72 @@ export function DashboardAgent({
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
}, []);

const openWithWatch = useCallback((spec: WatchSpec) => {
setOpen(true);
setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 }));
}, []);

// The dot's poll, and the toast's. Runs only while the panel is CLOSED — an
// open panel shows the wake in the transcript, so polling then would only race
// the read marker. Both the interval and the on-close refresh come from this
// effect re-running on `open`.
useEffect(() => {
if (!hasAccess || open) return;

let cancelled = false;
const load = async () => {
try {
const res = await fetch(`${actionPath}?unread=1`);
if (!res.ok) return;
const data = (await res.json()) as { unreadWakes?: number; wakes?: WatchWake[] };
if (cancelled) return;
setUnreadWakes(data.unreadWakes ?? 0);

const fresh = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId));
for (const wake of fresh) toastedWakes.current.add(wake.watchId);

// A burst gets one summary toast: a stack of persistent toasts is a wall,
// not a notification.
if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) {
showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true));
} else {
// Oldest first, so the newest wake ends up nearest the user.
for (const wake of [...fresh].reverse()) {
showWatchWakeToast(wake, openChat);
}
}
} catch {
// Offline or a hiccup — leave the dot as it is and try again next tick.
}
};

void load();
const interval = window.setInterval(load, UNREAD_POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [hasAccess, open, actionPath, setPanelOpen, openChat]);

// A chat the user is now looking at has no unread wakes. Zeroes the dot right
// away (the poll restores the truth on close if another chat still has one) and
// persists the read marker for the chat that's actually visible.
const markChatRead = useCallback(
async (chatId: string) => {
setUnreadWakes(0);
const body = new FormData();
body.set("intent", "read");
body.set("chatId", chatId);
try {
await fetch(actionPath, { method: "POST", body });
} catch {
// Not worth surfacing: the marker is caught up the next time the chat is
// opened.
}
},
[actionPath]
);

// ⌘J toggles the panel. Opening mounts the composer, which focuses itself, so
// the shortcut lands you in the text field. Enabled inside inputs too, so the
// same keystroke closes the panel while you're typing in it.
Expand All @@ -71,8 +186,8 @@ export function DashboardAgent({
useDashboardAgentOpenRequests({ enabled: hasAccess, openWith, setOpen: setPanelOpen });

const context = useMemo(
() => ({ open, setOpen: setPanelOpen, openWith }),
[open, setPanelOpen, openWith]
() => ({ open, setOpen: setPanelOpen, openWith, openWithWatch, unreadWakes }),
[open, setPanelOpen, openWith, openWithWatch, unreadWakes]
);

if (!hasAccess) {
Expand All @@ -95,7 +210,10 @@ export function DashboardAgent({
<DashboardAgentPanel
onClose={() => setPanelOpen(false)}
requestedMessage={requestedMessage}
openChatRequest={openChatRequest}
watchRequest={watchRequest}
promotedPrompt={promotedPrompt}
onChatRead={markChatRead}
/>
</ResizablePanel>
</ResizablePanelGroup>
Expand Down
69 changes: 64 additions & 5 deletions apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "@ai-sdk/react";
import type { dashboardAgent } from "@internal/dashboard-agent";
import type { AgentIntent, SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import type { AgentIntent, SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts";
import { useNavigate } from "@remix-run/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useCallback, useEffect, useRef, useState } from "react";
Expand All @@ -16,6 +16,7 @@ import { appendRunFilters, pendingNavigateIntents } from "./navigate-target";
import type { AgentPageContext } from "./page-context-types";
import { useAgentMessageQuota } from "./useAgentMessageQuota";
import { useTriggerUriResolver } from "./useTriggerUriResolver";
import { WatchChips, type WatchChip } from "./WatchChips";

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

const {
messages: rawMessages,
setMessages,
sendMessage,
status,
stop: aiStop,
Expand Down Expand Up @@ -199,6 +224,20 @@ export function DashboardAgentChat({
const activity: TurnActivity | null =
status === "submitted" ? "thinking" : status === "streaming" ? "working" : null;

// A server-appended block (the watch card's outcome) joins the live transcript
// in place. Applied once per `seq`: the append is already persisted, so
// replaying it would show the same confirmation twice.
const appendedSeq = useRef<number | undefined>(undefined);
useEffect(() => {
if (!appendedMessage || appendedSeq.current === appendedMessage.seq) return;
appendedSeq.current = appendedMessage.seq;
setMessages((current) =>
current.some((message) => message.id === appendedMessage.message.id)
? current
: [...current, appendedMessage.message]
);
}, [appendedMessage, setMessages]);

// Cold start: trigger the first turn by sending the pending message once.
const sentFirst = useRef(false);
useEffect(() => {
Expand Down Expand Up @@ -263,8 +302,14 @@ export function DashboardAgentChat({
);

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

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

return (
<>
{/* What this chat is watching, at the top of the panel: a watch outcome
arrives in the transcript unprompted, so the chips are what explain
where those messages will come from. */}
{/* Chips are an offer to cancel, so only live watches get one; the full
list still flows to the messages for the wake banner's tone. */}
<WatchChips
watches={watches.filter((watch) => watch.status === "active")}
onCancel={onCancelWatch}
/>
{/* A cold-start chat mounts with no messages and a first message about to
be sent, so the prompts would flash for a frame before the transcript
replaced them. Gate on that pending send. */}
Expand All @@ -342,9 +399,11 @@ export function DashboardAgentChat({
onDismissError={clearError}
onIntent={handleIntent}
pagePaths={pagePaths}
watches={watches}
resolveUri={resolveUri}
/>
)}
{watchCard}
{/* The Free plan's message cap occupies the composer slot: at the cap the
composer is replaced by the upgrade block (a composer you can't send
from is worse than none), and under it the composer is followed by the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function DashboardAgentDraft({
currentPage,
pageContext,
promotedPrompt,
watchCard,
}: {
onSubmit: (text: string) => void;
projectSlug: string;
Expand All @@ -28,6 +29,8 @@ export function DashboardAgentDraft({
pageContext?: AgentPageContext;
// The product-controlled promoted chip, from the feature flag.
promotedPrompt?: SuggestedPrompt;
/** The ephemeral watch card, when one is open. Sits above the composer. */
watchCard?: React.ReactNode;
}) {
const [input, setInput] = useState("");

Expand All @@ -48,6 +51,7 @@ export function DashboardAgentDraft({
pageContext={pageContext}
promoted={promotedPrompt}
/>
{watchCard}
<DashboardAgentComposer
value={input}
onChange={setInput}
Expand Down
Loading
Loading