Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
3cd4102
feat(dashboard-agent): move the watch data layer in from the base PR
kathiekiwi Aug 8, 2026
da93873
feat(webapp): tell the user later — the dashboard agent's Watch
kathiekiwi Aug 6, 2026
994d2f4
fix(webapp): one watch button per answer, not two
kathiekiwi Aug 7, 2026
aa11e97
fix(webapp): stop saying error twice when naming a watched error
kathiekiwi Aug 7, 2026
beb53d0
fix(webapp): name a watched error in full, not truncated
kathiekiwi Aug 7, 2026
ae331fe
fix(webapp): give a watch email the same headline the chat shows
kathiekiwi Aug 7, 2026
b1454ed
docs(dashboard-agent): say where each agent button appears, and on wh…
kathiekiwi Aug 7, 2026
36de378
fix(dashboard-agent): stop reporting a queue as missing when only its…
kathiekiwi Aug 7, 2026
30c90f7
fix(webapp): no watch offer on a paused queue, in the button or the c…
kathiekiwi Aug 7, 2026
5d40e2d
fix(webapp): a paused queue offers no backlog chips either
kathiekiwi Aug 7, 2026
7791ccc
fix(dashboard-agent): say a queue is paused before reciting its metrics
kathiekiwi Aug 7, 2026
e662217
fix(dashboard-agent): name the state that explains the numbers, and s…
kathiekiwi Aug 7, 2026
5c86d18
fix(webapp): let the agent read a queue's own row, not only its metrics
kathiekiwi Aug 7, 2026
c2ab6b6
test(webapp): pin every tool's API against the scope its route author…
kathiekiwi Aug 7, 2026
762a6fe
fix(dashboard-agent): mint the environment JWT with the scopes the to…
kathiekiwi Aug 7, 2026
c6314c6
feat(webapp): mark a chat unread when work finished behind a closed p…
kathiekiwi Aug 7, 2026
27920f7
feat(webapp): poll for unread work while the panel is closed
kathiekiwi Aug 7, 2026
d51b01c
fix(webapp): keep the wake poll running for a turn started behind a c…
kathiekiwi Aug 7, 2026
804c0c9
fix(dashboard-agent): tell get_queue who feeds a custom queue
kathiekiwi Aug 7, 2026
c25dd6d
refactor(dashboard-agent): factor the repeated shapes out of render_v…
kathiekiwi Aug 7, 2026
227c1c8
test(webapp): regenerate the watch wording snapshot for the untruncat…
kathiekiwi Aug 7, 2026
4fc64a2
test(webapp): give the watch container tests a timeout that fits boot…
kathiekiwi Aug 7, 2026
137c79a
fix(webapp): send a task queue's stored name to the watch it offers
kathiekiwi Aug 7, 2026
ef148ea
fix(webapp): claim the alert dispatch before the watch sweep fans out
kathiekiwi Aug 7, 2026
07e60f9
fix(webapp): normalise an error watch's fingerprint before its identity
kathiekiwi Aug 7, 2026
34c24a9
fix(webapp): let a wait-time watch end when its queue is gone or unre…
kathiekiwi Aug 7, 2026
5125f7c
fix(dashboard-agent): fail a watch investigation whose card refuses t…
kathiekiwi Aug 7, 2026
6d22d17
fix(webapp): count unread chats from the list instead of subtracting …
kathiekiwi Aug 8, 2026
ed67d38
fix(webapp): drop a watch draft when the panel changes chat
kathiekiwi Aug 8, 2026
adae596
fix(webapp): keep the delete-chat confirmation alive outside the hist…
kathiekiwi Aug 8, 2026
400d6ee
fix(dashboard-agent): tie a watch's investigation to the watch, not t…
kathiekiwi Aug 8, 2026
eea14d3
feat(webapp): count wake-feed requests
kathiekiwi Aug 8, 2026
89f2fb9
test(webapp): pin read:queues on both of the agent's scope allowlists
kathiekiwi Aug 8, 2026
35d23fa
fix(webapp): cancel a dashboard agent turn when the user leaves the page
kathiekiwi Aug 8, 2026
54b719f
fix(webapp): scope a turn's navigation to the page it was asked on
kathiekiwi Aug 8, 2026
52ac078
fix(webapp): send every prompt the user clicks, never just fill the c…
kathiekiwi Aug 8, 2026
43a439b
fix(dashboard-agent): read the report's untrustworthy reason under it…
kathiekiwi Aug 8, 2026
9d0b65f
fix(dashboard-agent): report the line read_file actually reached when…
kathiekiwi Aug 8, 2026
81cda8c
fix(dashboard-agent): refuse a view carrying two investigations inste…
kathiekiwi Aug 8, 2026
a25779e
fix(dashboard-agent-db): require a role on the messages the append-on…
kathiekiwi Aug 8, 2026
630220d
test(dashboard-agent): assert the chart validation window outside the…
kathiekiwi Aug 8, 2026
ca20366
refactor(webapp): type the query scope ceiling by credential kind, no…
kathiekiwi Aug 8, 2026
69d2e4e
test(webapp): close the watch suites' agent-db pool and give the slow…
kathiekiwi Aug 8, 2026
2ae2905
docs(dashboard-agent): say that the health verbs rewrite the environm…
kathiekiwi Aug 8, 2026
6068f5a
fix(dashboard-agent): read the alerts API's real response shape
kathiekiwi Aug 8, 2026
94e7fb7
fix(dashboard-agent): stop a run of failed watch checks nesting forever
kathiekiwi Aug 8, 2026
346d8ef
fix(webapp): decide the one-watch-button rule per turn, not per rende…
kathiekiwi Aug 8, 2026
c43f6a4
fix(webapp): don't re-toast a watch wake another browser already read
kathiekiwi Aug 8, 2026
2558a9b
fix(webapp): put the watch chip's tooltip back in the tab order
kathiekiwi Aug 8, 2026
8f04923
fix(webapp): guard the scenario kit's app origin like its other hosts
kathiekiwi Aug 8, 2026
92ca8c0
fix(dashboard-agent-db): backfill last_read_at so existing chats star…
kathiekiwi Aug 8, 2026
4e17aa0
test(webapp): let the chat-ordering test load where the route reaches…
kathiekiwi Aug 8, 2026
d5d4325
test(webapp): close the watch tenancy suite's agent db pool
kathiekiwi Aug 8, 2026
d076c3c
fix(webapp): restore the soft-delete import a rebase dropped
kathiekiwi Aug 8, 2026
161bd15
test(webapp): match the per-turn watch rule by shape, not by line breaks
kathiekiwi Aug 8, 2026
3388924
fix(webapp): treat a corrupt watch-activity key as nothing known
kathiekiwi Aug 8, 2026
bd89c83
fix(webapp): keep the environments a watch-alert channel already covers
kathiekiwi Aug 8, 2026
94f7983
fix(dashboard-agent): tell a queue that can't be read apart from one …
kathiekiwi Aug 8, 2026
8e578ac
fix(webapp): stop the launcher dot counting the chat the user is reading
kathiekiwi Aug 8, 2026
5eae965
fix(dashboard-agent): summarise what the transcript recorded about a …
kathiekiwi Aug 8, 2026
bc70bd7
test(webapp): prove a dropped navigation stays dropped
kathiekiwi Aug 8, 2026
0541e6d
test(webapp): pin the query string where it can actually change
kathiekiwi Aug 8, 2026
222a667
test(webapp): default SESSION_SECRET in setup, not at module scope
kathiekiwi Aug 8, 2026
e8d5292
fix(webapp): cap a public API key's query scope like a public access …
kathiekiwi Aug 8, 2026
b629f33
fix(webapp): strip every task/ prefix from a stored queue name
kathiekiwi Aug 8, 2026
80a4756
docs(dashboard-agent): count the watch kinds the spec union has
kathiekiwi Aug 8, 2026
7d39ee8
test(webapp): give the contract test the watch block's fixture
kathiekiwi Aug 8, 2026
40ab4da
chore(webapp): drop the watch scenario kit
kathiekiwi Aug 8, 2026
70e01a3
docs(dashboard-agent): state what makes a watch fire, not how to repr…
kathiekiwi Aug 8, 2026
4961415
fix(webapp,dashboard-agent-db): settle read state, the dot and unavai…
kathiekiwi Aug 9, 2026
b8910fe
feat(webapp): let the agent's environment JWT read a queue's own row
kathiekiwi Aug 9, 2026
fcc161f
fix(webapp,dashboard-agent): settle the work count on the chat on scr…
kathiekiwi Aug 9, 2026
c86c2ed
style(webapp): drop an import the chat no longer uses
kathiekiwi Aug 9, 2026
e954d82
fix(webapp): stop a resumed turn navigating the tab that only picked …
kathiekiwi Aug 9, 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
208 changes: 205 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,93 @@ 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. Read by the poll callback,
// which outlives the render that started it, so it has to be a ref.
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 +133,142 @@ 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 {
// The chat on screen is being read, so the server leaves it out of the work count
// rather than the client subtracting it back off afterwards.
const onScreen = visibleChat.current;
// Bounded, so one stuck request can't hold the poll's in-flight guard.
const res = await fetch(
`${actionPath}?unread=1${onScreen ? `&chatId=${encodeURIComponent(onScreen)}` : ""}`,
{ 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(Math.max(0, data.unreadWork ?? 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 +303,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 +333,14 @@ export function DashboardAgent({
<DashboardAgentPanel
onClose={() => setPanelOpen(false)}
requestedMessage={requestedMessage}
openChatRequest={openChatRequest}
watchRequest={watchRequest}
newChatSeq={newChatSeq}
promotedPrompt={promotedPrompt}
onChatRead={markChatRead}
// The panel's own count, off the chat list it has already marked read.
onUnreadWorkChange={setUnreadWork}
onTurnActivityChange={handleTurnActivityChange}
isFullscreen={fullscreen}
onToggleFullscreen={toggleFullscreen}
/>
Expand Down
Loading
Loading