Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
b049e5d
feat(webapp): tell the user later — the dashboard agent's Watch
kathiekiwi Aug 6, 2026
b7db5d7
fix(webapp): one watch button per answer, not two
kathiekiwi Aug 7, 2026
e0f849c
fix(webapp): stop saying error twice when naming a watched error
kathiekiwi Aug 7, 2026
327d34c
fix(webapp): name a watched error in full, not truncated
kathiekiwi Aug 7, 2026
938e684
fix(webapp): give a watch email the same headline the chat shows
kathiekiwi Aug 7, 2026
afcbd3d
docs(dashboard-agent): say where each agent button appears, and on wh…
kathiekiwi Aug 7, 2026
6b5e68a
fix(dashboard-agent): stop reporting a queue as missing when only its…
kathiekiwi Aug 7, 2026
385b41f
fix(webapp): no watch offer on a paused queue, in the button or the c…
kathiekiwi Aug 7, 2026
4f44c25
fix(webapp): a paused queue offers no backlog chips either
kathiekiwi Aug 7, 2026
b766ad2
fix(dashboard-agent): say a queue is paused before reciting its metrics
kathiekiwi Aug 7, 2026
d9018fe
fix(dashboard-agent): name the state that explains the numbers, and s…
kathiekiwi Aug 7, 2026
b98e0e8
fix(webapp): let the agent read a queue's own row, not only its metrics
kathiekiwi Aug 7, 2026
ec2e8e0
test(webapp): pin every tool's API against the scope its route author…
kathiekiwi Aug 7, 2026
1166494
fix(dashboard-agent): mint the environment JWT with the scopes the to…
kathiekiwi Aug 7, 2026
1cff901
feat(webapp): mark a chat unread when work finished behind a closed p…
kathiekiwi Aug 7, 2026
94bd369
feat(webapp): poll for unread work while the panel is closed
kathiekiwi Aug 7, 2026
dbfaab3
fix(webapp): keep the wake poll running for a turn started behind a c…
kathiekiwi Aug 7, 2026
ed382b8
fix(dashboard-agent): tell get_queue who feeds a custom queue
kathiekiwi Aug 7, 2026
18ff491
refactor(dashboard-agent): factor the repeated shapes out of render_v…
kathiekiwi Aug 7, 2026
56d2220
test(webapp): regenerate the watch wording snapshot for the untruncat…
kathiekiwi Aug 7, 2026
35652f7
test(webapp): give the watch container tests a timeout that fits boot…
kathiekiwi Aug 7, 2026
454db85
fix(webapp): send a task queue's stored name to the watch it offers
kathiekiwi Aug 7, 2026
9198e00
fix(webapp): claim the alert dispatch before the watch sweep fans out
kathiekiwi Aug 7, 2026
adcacf7
fix(webapp): normalise an error watch's fingerprint before its identity
kathiekiwi Aug 7, 2026
7449202
fix(webapp): let a wait-time watch end when its queue is gone or unre…
kathiekiwi Aug 7, 2026
5692a65
fix(dashboard-agent): fail a watch investigation whose card refuses t…
kathiekiwi Aug 7, 2026
643bd25
fix(webapp): count unread chats from the list instead of subtracting …
kathiekiwi Aug 8, 2026
76e9e17
fix(webapp): drop a watch draft when the panel changes chat
kathiekiwi Aug 8, 2026
ba7694d
fix(webapp): keep the delete-chat confirmation alive outside the hist…
kathiekiwi Aug 8, 2026
8bc9bfb
fix(dashboard-agent): tie a watch's investigation to the watch, not t…
kathiekiwi Aug 8, 2026
cb6c507
feat(webapp): count wake-feed requests
kathiekiwi Aug 8, 2026
3500adb
test(webapp): pin read:queues on both of the agent's scope allowlists
kathiekiwi Aug 8, 2026
081faa6
fix(dashboard-agent): pin the investigation cards the transcript actu…
kathiekiwi Aug 8, 2026
8dfc17b
fix(webapp): cancel a dashboard agent turn when the user leaves the page
kathiekiwi Aug 8, 2026
5ade76c
fix(webapp): scope a turn's navigation to the page it was asked on
kathiekiwi Aug 8, 2026
338681d
fix(webapp): send every prompt the user clicks, never just fill the c…
kathiekiwi Aug 8, 2026
0a7b93d
fix(dashboard-agent): read the report's untrustworthy reason under it…
kathiekiwi Aug 8, 2026
2ab9d03
fix(dashboard-agent): report the line read_file actually reached when…
kathiekiwi Aug 8, 2026
50a17a4
fix(dashboard-agent): refuse a view carrying two investigations inste…
kathiekiwi Aug 8, 2026
718c204
fix(dashboard-agent): stop a retrieved run's own error counting as a …
kathiekiwi Aug 8, 2026
d1c39a9
fix(dashboard-agent-db): require a role on the messages the append-on…
kathiekiwi Aug 8, 2026
cfcfd45
test(dashboard-agent): assert the chart validation window outside the…
kathiekiwi Aug 8, 2026
ab58325
refactor(webapp): type the query scope ceiling by credential kind, no…
kathiekiwi Aug 8, 2026
f9238d7
test(webapp): close the watch suites' agent-db pool and give the slow…
kathiekiwi Aug 8, 2026
6244f58
docs(dashboard-agent): say that the health verbs rewrite the environm…
kathiekiwi Aug 8, 2026
5ea7077
fix(dashboard-agent): read the alerts API's real response shape
kathiekiwi Aug 8, 2026
653e2cd
fix(dashboard-agent): stop a run of failed watch checks nesting forever
kathiekiwi Aug 8, 2026
38ed62f
fix(webapp): decide the one-watch-button rule per turn, not per rende…
kathiekiwi Aug 8, 2026
8069b42
fix(webapp): don't re-toast a watch wake another browser already read
kathiekiwi Aug 8, 2026
137aced
fix(webapp): put the watch chip's tooltip back in the tab order
kathiekiwi Aug 8, 2026
e02e0f7
fix(webapp): guard the scenario kit's app origin like its other hosts
kathiekiwi Aug 8, 2026
e110e90
fix(dashboard-agent-db): backfill last_read_at so existing chats star…
kathiekiwi Aug 8, 2026
bd3190f
test(webapp): let the chat-ordering test load where the route reaches…
kathiekiwi Aug 8, 2026
8bb665e
test(webapp): close the watch tenancy suite's agent db pool
kathiekiwi Aug 8, 2026
c0f0058
test(webapp): point the Cmd-I shortcut assertion at the real export
kathiekiwi Aug 8, 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
5 changes: 5 additions & 0 deletions .changeset/quiet-hounds-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

The current-worker API now reports each task's queue, so you can see which tasks write to a given queue.
2 changes: 2 additions & 0 deletions .server-changes/dashboard-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Meet the dashboard agent: a chat in every environment that answers questions abo

**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.

**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.

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.

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.
Expand Down
8 changes: 6 additions & 2 deletions apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@ import type {
} from "@internal/dashboard-agent-contracts";
import { Button } from "~/components/primitives/Buttons";
import { ChatActionsRow } from "./chat-layout";
import { renderableActions } from "./view-actions";
import { renderableActions, withoutWatchActions } from "./view-actions";

export function ActionsBlock({
block,
onIntent,
dropWatch = false,
}: {
block: ActionsBlockPayload;
onIntent?: (intent: AgentIntent) => void;
/** Set when an investigation card in the same answer already offers the watch. */
dropWatch?: boolean;
}) {
const renderable = renderableActions(block.actions);
const actions = dropWatch ? withoutWatchActions(block.actions) : block.actions;
const renderable = renderableActions(actions);
if (!onIntent || renderable.length === 0) return null;
return (
<ChatActionsRow>
Expand Down
203 changes: 200 additions & 3 deletions apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts";
import { useLocation } from "@remix-run/react";
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 { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
import { agentDeepLinkParams, ASK_AI_SHORTCUT, askAiChannelTarget } from "./ask-ai-channels";
Expand All @@ -18,18 +21,92 @@ import {
readAgentFullscreen,
writeAgentFullscreen,
} from "./panel-layout";
import { nextPendingTurnChatId } from "./pending-turn";
import { nextVisibleChat } from "./unread-counts";
import { startWakePolling, wakesToToast } from "./wake-poll";
import { shouldPollWakeFeed, subscribeWatchActivity } from "./watch-activity";
import {
showWatchWakesSummaryToast,
showWatchWakeToast,
WAKE_TOAST_MAX_INDIVIDUAL,
type WatchWake,
} from "./WatchWakeToast";

const TOASTED_WAKES_STORAGE_KEY = "tdev:dashboard-agent:toasted-wakes";

// Shorter than the poll interval, so a stuck request is dropped before the next tick.
const UNREAD_REQUEST_TIMEOUT_MS = 30_000;

/** `hasAccess` is a UI gate only; the resource routes enforce the same check server-side. */
export function DashboardAgent({
children,
hasAccess = false,
promotedPrompt,
/** From the page load: unread wakes waiting for this user, whatever this browser remembers. */
initialUnreadWakes = 0,
initialUnreadWork = 0,
/** Also from the page load: a watch is running, so a wake can still arrive in this tab. */
hasActiveWatches = false,
}: {
children: React.ReactNode;
hasAccess?: boolean;
promotedPrompt?: SuggestedPrompt;
initialUnreadWakes?: number;
/** Chats whose transcript moved on since their owner last looked. */
initialUnreadWork?: number;
hasActiveWatches?: boolean;
}) {
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);
// Seeded from the page load, so the launcher dot is right before the first poll answers.
const [unreadWakes, setUnreadWakes] = useState(initialUnreadWakes);
Comment thread
kathiekiwi marked this conversation as resolved.
// Work that finished behind a closed panel. Counted server-side on page load and refreshed
// with the chat list; the wake poll doesn't carry it.
const [unreadWork, setUnreadWork] = useState(initialUnreadWork);
// A turn this tab started may finish after the panel closes; that is exactly the case the
// dot exists for, so the poll has to be running when it lands.
const [pendingTurnChatId, setPendingTurnChatId] = useState<string | null>(null);
const handleTurnActivityChange = useCallback((chatId: string, active: boolean) => {
setPendingTurnChatId((current) => nextPendingTurnChatId(current, { chatId, active }));
}, []);
const toastedWakes = useRef(new Set<string>());
// The toast source is recent deliveries, not unread, so the dedupe must survive a reload.
useEffect(() => {
try {
const raw = window.localStorage.getItem(TOASTED_WAKES_STORAGE_KEY);
if (raw) for (const id of JSON.parse(raw) as string[]) toastedWakes.current.add(id);
} catch {
// Storage unavailable; the in-memory dedupe still applies.
}
}, []);
const rememberToasted = useCallback((watchId: string) => {
toastedWakes.current.add(watchId);
try {
// Newest ids only, so the key can't grow unbounded.
window.localStorage.setItem(
TOASTED_WAKES_STORAGE_KEY,
JSON.stringify([...toastedWakes.current].slice(-50))
);
} catch {
// Same as the read.
}
}, []);
// A wake in the on-screen chat toasts but must not light the dot.
const visibleChat = useRef<string | null>(null);

// Switching environment re-runs the layout loader but does not remount it, so the seeds
// above would keep the old environment's counts.
const seededEnvironment = useRef(environment.id);
useEffect(() => {
if (seededEnvironment.current === environment.id) return;
seededEnvironment.current = environment.id;
setUnreadWakes(initialUnreadWakes);
setUnreadWork(initialUnreadWork);
}, [environment.id, initialUnreadWakes, initialUnreadWork]);
// Read lazily so SSR always renders the side panel.
const [fullscreen, setFullscreen] = useState(readAgentFullscreen);

Expand All @@ -55,24 +132,139 @@ export function DashboardAgent({
const [requestedMessage, setRequestedMessage] = useState<
{ text: string; seq: number } | undefined
>(undefined);
// `seq` so the same chat can be asked for twice.
const [openChatRequest, setOpenChatRequest] = useState<
{ chatId: string; seq: number } | undefined
>(undefined);
const [watchRequest, setWatchRequest] = useState<{ spec: WatchSpec; seq: number } | undefined>(
undefined
);

const setPanelOpen = useCallback((next: boolean) => {
setOpen(next);
// Pending requests must be dropped or a stale one re-applies on the next open.
if (!next) {
visibleChat.current = null;
setFullscreen(false);
writeAgentFullscreen(false);
setRequestedMessage(undefined);
setOpenChatRequest(undefined);
setWatchRequest(undefined);
}
}, []);

const openChat = useCallback((chatId: string) => {
setOpen(true);
setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
}, []);

const openWith = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setOpen(true);
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
}, []);

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

// Nothing to be woken about means nothing to poll for. The page load's unread count and
// active-watch flag are the ungated signals; the browser's own memory of a watch starts the
// poll without a reload. Once any says yes this tab keeps polling, so a wake reaches a tab
// that was open before the watch existed.
const [watching, setWatching] = useState(false);
useEffect(() => {
const sync = () => {
if (
shouldPollWakeFeed({
serverUnreadWakes: initialUnreadWakes,
serverHasActiveWatches: hasActiveWatches,
serverUnreadWork: initialUnreadWork,
turnInFlight: pendingTurnChatId !== null,
organizationId: organization.id,
})
)
setWatching(true);
};
sync();
return subscribeWatchActivity(sync);
}, [organization.id, initialUnreadWakes, hasActiveWatches, initialUnreadWork, pendingTurnChatId]);

useEffect(() => {
if (!hasAccess || !watching) return;

let cancelled = false;
const load = async () => {
try {
// Bounded, so one stuck request can't hold the poll's in-flight guard.
const res = await fetch(`${actionPath}?unread=1`, {
signal: AbortSignal.timeout(UNREAD_REQUEST_TIMEOUT_MS),
});
if (!res.ok) return;
const data = (await res.json()) as {
unreadWakes?: number;
unreadWork?: number;
wakes?: WatchWake[];
};
if (cancelled) return;
// The wakes list carries read ones too, so only unread ones are subtracted.
const unreadInView = (data.wakes ?? []).filter(
(wake) => wake.unread && wake.chatId === visibleChat.current
).length;
setUnreadWakes(Math.max(0, (data.unreadWakes ?? 0) - unreadInView));
// A chat open in the panel is being read right now, so it isn't unread work.
setUnreadWork(Math.max(0, (data.unreadWork ?? 0) - (open && visibleChat.current ? 1 : 0)));

const fresh = wakesToToast(data.wakes, toastedWakes.current);
for (const wake of fresh) rememberToasted(wake.watchId);

if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) {
showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true));
} else {
for (const wake of [...fresh].reverse()) {
showWatchWakeToast(wake, openChat);
}
}
} catch {
// Try again next tick.
}
};

const stop = startWakePolling({
load,
isHidden: () => document.hidden,
onVisibilityChange: (listener) => {
document.addEventListener("visibilitychange", listener);
return () => document.removeEventListener("visibilitychange", listener);
},
});

return () => {
cancelled = true;
stop();
};
}, [hasAccess, watching, actionPath, setPanelOpen, openChat]);

// Zeroes the wake dot right away; the poll restores the truth if another chat has one. The
// work count is not touched here: the panel derives it from the chat list.
const markChatRead = useCallback(
async (chatId: string, options: { leaving: boolean }) => {
visibleChat.current = nextVisibleChat(chatId, options);
setUnreadWakes(0);
const body = new FormData();
body.set("intent", "read");
body.set("chatId", chatId);
try {
await fetch(actionPath, { method: "POST", body });
} catch {
// Catches up on the next open.
}
},
[actionPath]
);

// ⌘J is contextual: closed opens the panel, open starts a new chat. It never closes.
useShortcutKeys({
shortcut: TOGGLE_PANEL_SHORTCUT,
Expand Down Expand Up @@ -107,8 +299,8 @@ export function DashboardAgent({
});

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

if (!hasAccess) {
Expand Down Expand Up @@ -137,8 +329,13 @@ export function DashboardAgent({
<DashboardAgentPanel
onClose={() => setPanelOpen(false)}
requestedMessage={requestedMessage}
openChatRequest={openChatRequest}
watchRequest={watchRequest}
newChatSeq={newChatSeq}
promotedPrompt={promotedPrompt}
onChatRead={markChatRead}
onUnreadWorkChange={setUnreadWork}
onTurnActivityChange={handleTurnActivityChange}
isFullscreen={fullscreen}
onToggleFullscreen={toggleFullscreen}
/>
Expand Down
Loading
Loading