Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
646fd39
feat(dashboard-agent): move the watch data layer in from the base PR
kathiekiwi Aug 8, 2026
3dd9f76
feat(webapp): tell the user later — the dashboard agent's Watch
kathiekiwi Aug 6, 2026
0893850
fix(webapp): one watch button per answer, not two
kathiekiwi Aug 7, 2026
56c3d96
fix(webapp): stop saying error twice when naming a watched error
kathiekiwi Aug 7, 2026
b994f64
fix(webapp): name a watched error in full, not truncated
kathiekiwi Aug 7, 2026
0805218
fix(webapp): give a watch email the same headline the chat shows
kathiekiwi Aug 7, 2026
a7f1d5b
docs(dashboard-agent): say where each agent button appears, and on wh…
kathiekiwi Aug 7, 2026
d4daf3a
fix(dashboard-agent): stop reporting a queue as missing when only its…
kathiekiwi Aug 7, 2026
6171775
fix(webapp): no watch offer on a paused queue, in the button or the c…
kathiekiwi Aug 7, 2026
e0fcde3
fix(webapp): a paused queue offers no backlog chips either
kathiekiwi Aug 7, 2026
867b8a0
fix(dashboard-agent): say a queue is paused before reciting its metrics
kathiekiwi Aug 7, 2026
4f69309
fix(dashboard-agent): name the state that explains the numbers, and s…
kathiekiwi Aug 7, 2026
9a64522
fix(webapp): let the agent read a queue's own row, not only its metrics
kathiekiwi Aug 7, 2026
1cc5e0c
test(webapp): pin every tool's API against the scope its route author…
kathiekiwi Aug 7, 2026
5385797
fix(dashboard-agent): mint the environment JWT with the scopes the to…
kathiekiwi Aug 7, 2026
d4d9bfa
feat(webapp): mark a chat unread when work finished behind a closed p…
kathiekiwi Aug 7, 2026
8155016
feat(webapp): poll for unread work while the panel is closed
kathiekiwi Aug 7, 2026
8fda308
fix(webapp): keep the wake poll running for a turn started behind a c…
kathiekiwi Aug 7, 2026
eb52eb9
fix(dashboard-agent): tell get_queue who feeds a custom queue
kathiekiwi Aug 7, 2026
bf196fa
refactor(dashboard-agent): factor the repeated shapes out of render_v…
kathiekiwi Aug 7, 2026
a1a8824
test(webapp): regenerate the watch wording snapshot for the untruncat…
kathiekiwi Aug 7, 2026
e3ef20f
test(webapp): give the watch container tests a timeout that fits boot…
kathiekiwi Aug 7, 2026
a2e94f3
fix(webapp): send a task queue's stored name to the watch it offers
kathiekiwi Aug 7, 2026
ed14338
fix(webapp): claim the alert dispatch before the watch sweep fans out
kathiekiwi Aug 7, 2026
8e1f298
fix(webapp): normalise an error watch's fingerprint before its identity
kathiekiwi Aug 7, 2026
ee22895
fix(webapp): let a wait-time watch end when its queue is gone or unre…
kathiekiwi Aug 7, 2026
69af425
fix(dashboard-agent): fail a watch investigation whose card refuses t…
kathiekiwi Aug 7, 2026
10ad03b
fix(webapp): count unread chats from the list instead of subtracting …
kathiekiwi Aug 8, 2026
08ba380
fix(webapp): drop a watch draft when the panel changes chat
kathiekiwi Aug 8, 2026
180d51f
fix(webapp): keep the delete-chat confirmation alive outside the hist…
kathiekiwi Aug 8, 2026
59e3d7c
fix(dashboard-agent): tie a watch's investigation to the watch, not t…
kathiekiwi Aug 8, 2026
0f52180
feat(webapp): count wake-feed requests
kathiekiwi Aug 8, 2026
1bfa694
test(webapp): pin read:queues on both of the agent's scope allowlists
kathiekiwi Aug 8, 2026
e762ba3
fix(webapp): cancel a dashboard agent turn when the user leaves the page
kathiekiwi Aug 8, 2026
1c08cc5
fix(webapp): scope a turn's navigation to the page it was asked on
kathiekiwi Aug 8, 2026
798db92
fix(webapp): send every prompt the user clicks, never just fill the c…
kathiekiwi Aug 8, 2026
9dfe90a
fix(dashboard-agent): read the report's untrustworthy reason under it…
kathiekiwi Aug 8, 2026
e134dc1
fix(dashboard-agent): report the line read_file actually reached when…
kathiekiwi Aug 8, 2026
a663188
fix(dashboard-agent): refuse a view carrying two investigations inste…
kathiekiwi Aug 8, 2026
cde6803
fix(dashboard-agent-db): require a role on the messages the append-on…
kathiekiwi Aug 8, 2026
2063e2b
test(dashboard-agent): assert the chart validation window outside the…
kathiekiwi Aug 8, 2026
72948fe
refactor(webapp): type the query scope ceiling by credential kind, no…
kathiekiwi Aug 8, 2026
5b04344
test(webapp): close the watch suites' agent-db pool and give the slow…
kathiekiwi Aug 8, 2026
47b96ea
docs(dashboard-agent): say that the health verbs rewrite the environm…
kathiekiwi Aug 8, 2026
68cd583
fix(dashboard-agent): read the alerts API's real response shape
kathiekiwi Aug 8, 2026
ef8ef07
fix(dashboard-agent): stop a run of failed watch checks nesting forever
kathiekiwi Aug 8, 2026
c0a18c6
fix(webapp): decide the one-watch-button rule per turn, not per rende…
kathiekiwi Aug 8, 2026
ff8691c
fix(webapp): don't re-toast a watch wake another browser already read
kathiekiwi Aug 8, 2026
9c13606
fix(webapp): put the watch chip's tooltip back in the tab order
kathiekiwi Aug 8, 2026
8620aaf
fix(webapp): guard the scenario kit's app origin like its other hosts
kathiekiwi Aug 8, 2026
68a17e0
fix(dashboard-agent-db): backfill last_read_at so existing chats star…
kathiekiwi Aug 8, 2026
029c013
test(webapp): let the chat-ordering test load where the route reaches…
kathiekiwi Aug 8, 2026
ce729cc
test(webapp): close the watch tenancy suite's agent db pool
kathiekiwi Aug 8, 2026
49f7d78
test(webapp): point the Cmd-I shortcut assertion at the real export
kathiekiwi Aug 8, 2026
7736e93
fix(webapp): restore the soft-delete import a rebase dropped
kathiekiwi Aug 8, 2026
50e71c3
test(webapp): match the per-turn watch rule by shape, not by line breaks
kathiekiwi Aug 8, 2026
6698f66
fix(webapp): treat a corrupt watch-activity key as nothing known
kathiekiwi Aug 8, 2026
9de3219
fix(webapp): keep the environments a watch-alert channel already covers
kathiekiwi Aug 8, 2026
e2263fa
fix(dashboard-agent): tell a queue that can't be read apart from one …
kathiekiwi Aug 8, 2026
6db636b
fix(webapp): stop the launcher dot counting the chat the user is reading
kathiekiwi Aug 8, 2026
67d43a2
fix(dashboard-agent): summarise what the transcript recorded about a …
kathiekiwi Aug 8, 2026
c7a937c
test(webapp): prove a dropped navigation stays dropped
kathiekiwi Aug 8, 2026
cd20099
test(webapp): pin the query string where it can actually change
kathiekiwi Aug 8, 2026
eb8c91c
test(webapp): default SESSION_SECRET in setup, not at module scope
kathiekiwi Aug 8, 2026
0044822
fix(webapp): cap a public API key's query scope like a public access …
kathiekiwi Aug 8, 2026
6afc6be
fix(webapp): strip every task/ prefix from a stored queue name
kathiekiwi Aug 8, 2026
38d9e8c
docs(dashboard-agent): count the watch kinds the spec union has
kathiekiwi Aug 8, 2026
7a656a0
test(webapp): give the contract test the watch block's fixture
kathiekiwi Aug 8, 2026
b8229ac
chore(webapp): drop the watch scenario kit
kathiekiwi Aug 8, 2026
6fbdbbc
docs(dashboard-agent): state what makes a watch fire, not how to repr…
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 agent works on preview and dev branches, with that branch's own data.

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.
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
214 changes: 211 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,98 @@ import {
readAgentFullscreen,
writeAgentFullscreen,
} from "./panel-layout";
import { nextPendingTurnChatId } from "./pending-turn";
import { nextVisibleChat, unreadWorkForDot } 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);
// Read by the poll callback, which outlives the render that started it: `open` in its closure
// is whatever it was when polling began, and opening the panel does not restart the poll.
const panelOpen = useRef(open);
useEffect(() => {
panelOpen.current = open;
}, [open]);

// 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 +138,144 @@ 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));
setUnreadWork(
unreadWorkForDot({
reported: data.unreadWork,
panelOpen: panelOpen.current,
visibleChatId: visibleChat.current,
})
);

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 +310,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 +340,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