Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
28dd3ac
feat: the Watch feature, re-applied on top of the extracted base
kathiekiwi Aug 1, 2026
ceb0cb1
feat(watch): the resolution model end to end (TRI-12820)
kathiekiwi Aug 2, 2026
f768e0a
fix(watch): alert hygiene — no plan gate, own-email only, atomic unsu…
kathiekiwi Aug 2, 2026
8601540
feat(watch): the Watch card + investigate-on-attention (TRI-12820)
kathiekiwi Aug 2, 2026
4974f26
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-w…
kathiekiwi Aug 2, 2026
d95a40c
Merge remote-tracking branch 'origin/feat/dashboard-agent-flows' into…
kathiekiwi Aug 3, 2026
1572b88
Merge remote-tracking branch 'origin/feat/dashboard-agent-flows' into…
kathiekiwi Aug 3, 2026
1ca17ed
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-w…
kathiekiwi Aug 3, 2026
2a9da70
fix: complete the flows merge — fullscreen + hero reconciled with the…
kathiekiwi Aug 3, 2026
cf87e0b
Merge remote-tracking branch 'origin/feat/dashboard-agent-flows' into…
kathiekiwi Aug 3, 2026
3a0f4b4
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-w…
kathiekiwi Aug 3, 2026
e3b2b53
Merge remote-tracking branch 'origin/feat/dashboard-agent-flows' into…
kathiekiwi Aug 3, 2026
98539e8
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-w…
kathiekiwi Aug 3, 2026
179a684
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-w…
kathiekiwi Aug 3, 2026
d025cd4
Merge remote-tracking branch 'origin/feat/dashboard-agent-flows' into…
kathiekiwi Aug 3, 2026
e4141a4
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-w…
kathiekiwi Aug 3, 2026
e7477b3
feat(watch): queue conditions — back below N, stalled, oldest-age SLA…
kathiekiwi Aug 3, 2026
5254ab6
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-w…
kathiekiwi Aug 3, 2026
9f8528d
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-w…
kathiekiwi Aug 3, 2026
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-watch-queue-conditions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

A queue watch can now wait for three more things under **Customize**: the queue coming back below a number you pick, the queue stopping moving at all, and runs waiting longer than a limit you set. On a queue where runs are already waiting too long, the **Watch…** button opens on that wait instead of on "until it clears".
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,10 +1,13 @@
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";
Expand All @@ -15,6 +18,16 @@ import {
readAgentFullscreen,
writeAgentFullscreen,
} from "./panel-layout";
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 @@ -38,7 +51,18 @@ 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>());
// The side panel is the default; someone who last worked fullscreen gets
// fullscreen back. Read lazily so SSR always renders the side panel.
const [fullscreen, setFullscreen] = useState(readAgentFullscreen);
Expand All @@ -57,14 +81,39 @@ export function DashboardAgent({
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 @@ -74,6 +123,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 is contextual: closed → open the panel (the composer focuses itself, so
// the keystroke lands you in the text field); open → start a new chat.
// Closing is Esc or the header's ×, never ⌘J.
Expand All @@ -96,8 +211,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 Down Expand Up @@ -130,8 +245,11 @@ export function DashboardAgent({
<DashboardAgentPanel
onClose={() => setPanelOpen(false)}
requestedMessage={requestedMessage}
openChatRequest={openChatRequest}
watchRequest={watchRequest}
newChatSeq={newChatSeq}
promotedPrompt={promotedPrompt}
onChatRead={markChatRead}
isFullscreen={fullscreen}
onToggleFullscreen={toggleFullscreen}
/>
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 @@ -344,9 +401,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
Loading
Loading