Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
89b7522
feat(webapp): tell the user later — the dashboard agent's Watch
kathiekiwi Aug 6, 2026
b66e3ca
Merge branch 'feat/dashboard-agent-ui' into feat/dashboard-agent-flow…
kathiekiwi Aug 7, 2026
94be10b
Merge branch 'feat/dashboard-agent-ui' into feat/dashboard-agent-flow…
kathiekiwi Aug 7, 2026
847071f
Merge branch 'feat/dashboard-agent-ui' into feat/dashboard-agent-flow…
kathiekiwi Aug 7, 2026
e08a8de
Merge branch 'feat/dashboard-agent-ui' into feat/dashboard-agent-flow…
kathiekiwi Aug 7, 2026
4697e90
fix(webapp): one watch button per answer, not two
kathiekiwi Aug 7, 2026
4f26e67
fix(webapp): stop saying error twice when naming a watched error
kathiekiwi Aug 7, 2026
25c8ccf
fix(webapp): name a watched error in full, not truncated
kathiekiwi Aug 7, 2026
894a722
fix(webapp): give a watch email the same headline the chat shows
kathiekiwi Aug 7, 2026
98826ee
Merge branch 'feat/dashboard-agent-ui' into feat/dashboard-agent-flow…
kathiekiwi Aug 7, 2026
baa3971
fix(webapp): close the block the merge left open in view-actions tests
kathiekiwi Aug 7, 2026
46e72f3
docs(dashboard-agent): say where each agent button appears, and on wh…
kathiekiwi Aug 7, 2026
ed8d649
fix(dashboard-agent): stop reporting a queue as missing when only its…
kathiekiwi Aug 7, 2026
81e177a
fix(webapp): no watch offer on a paused queue, in the button or the c…
kathiekiwi Aug 7, 2026
2d40177
fix(webapp): a paused queue offers no backlog chips either
kathiekiwi Aug 7, 2026
dabe0c6
fix(dashboard-agent): say a queue is paused before reciting its metrics
kathiekiwi Aug 7, 2026
7c929fe
fix(dashboard-agent): name the state that explains the numbers, and s…
kathiekiwi Aug 7, 2026
b5c3788
fix(webapp): let the agent read a queue's own row, not only its metrics
kathiekiwi Aug 7, 2026
7d7adeb
test(webapp): pin every tool's API against the scope its route author…
kathiekiwi Aug 7, 2026
57f438a
fix(dashboard-agent): mint the environment JWT with the scopes the to…
kathiekiwi Aug 7, 2026
fa60e7d
revert(webapp): put the JWT exchange back to how main had it
kathiekiwi Aug 7, 2026
274141c
feat(webapp): mark a chat unread when work finished behind a closed p…
kathiekiwi Aug 7, 2026
6c04097
Merge branch 'feat/dashboard-agent-ui' into feat/dashboard-agent-flow…
kathiekiwi Aug 7, 2026
379b5f8
feat(webapp): poll for unread work while the panel is closed
kathiekiwi Aug 7, 2026
70d44bc
fix(webapp): keep the wake poll running for a turn started behind a c…
kathiekiwi Aug 7, 2026
2f27582
fix(dashboard-agent): tell get_queue who feeds a custom queue
kathiekiwi Aug 7, 2026
19c959c
Merge branch 'feat/dashboard-agent-ui' into feat/dashboard-agent-flow…
kathiekiwi Aug 7, 2026
fabd321
refactor(dashboard-agent): factor the repeated shapes out of render_v…
kathiekiwi Aug 7, 2026
dca8646
Merge branch 'feat/dashboard-agent-ui' into feat/dashboard-agent-flow…
kathiekiwi Aug 7, 2026
45c09ba
test(webapp): regenerate the watch wording snapshot for the untruncat…
kathiekiwi Aug 7, 2026
f2d153f
test(webapp): give the watch container tests a timeout that fits boot…
kathiekiwi Aug 7, 2026
2f75b20
Merge branch 'feat/dashboard-agent-ui' into feat/dashboard-agent-flow…
kathiekiwi Aug 7, 2026
0cb1962
fix(webapp): send a task queue's stored name to the watch it offers
kathiekiwi Aug 7, 2026
5838b6a
fix(webapp): claim the alert dispatch before the watch sweep fans out
kathiekiwi Aug 7, 2026
feadd6f
fix(webapp): normalise an error watch's fingerprint before its identity
kathiekiwi Aug 7, 2026
b71a752
fix(webapp): let a wait-time watch end when its queue is gone or unre…
kathiekiwi Aug 7, 2026
f5e8580
fix(dashboard-agent): fail a watch investigation whose card refuses t…
kathiekiwi Aug 7, 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
193 changes: 190 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 { DashboardAgentPanel } from "./DashboardAgentPanel";
import {
Expand All @@ -20,18 +23,81 @@ import {
readAgentFullscreen,
writeAgentFullscreen,
} from "./panel-layout";
import { nextPendingTurnChatId } from "./pending-turn";
import { startWakePolling } 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 on lines +66 to +67

@devin-ai-integration devin-ai-integration Bot Aug 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Unread count seeded once, so it can be stale across project/org navigation

initialUnreadWakes seeds useState only at mount, and this component lives in the environment layout route, which is not remounted when the org/project/env params change. After navigating to another project (or org), the badge keeps the previous scope's count until the next poll (~1 minute) answers on the new actionPath. The watching flag re-evaluates via its own effect (it depends on initialUnreadWakes/hasActiveWatches), so the poll does eventually correct it — but there is a window where the launcher can show a dot for another organization's unread wakes, or hide one that exists. Syncing the state when initialUnreadWakes changes would close it.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

// 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 lazily so SSR always renders the side panel.
const [fullscreen, setFullscreen] = useState(readAgentFullscreen);

Expand All @@ -57,24 +123,140 @@ 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 = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId));
for (const wake of fresh) rememberToasted(wake.watchId);

Comment on lines +211 to +213

@devin-ai-integration devin-ai-integration Bot Aug 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Recent wakes are toasted even when already read, on any browser without the local dedupe

The toast source is data.wakes, which is recent deliveries (the loader's 15-minute window), not unread ones — the unread flag is used only for the dot. Dedupe is toastedWakes, a localStorage set capped at 50 ids. So a user who reads a wake on machine A and then opens the dashboard on machine B (or after clearing site data) within 15 minutes gets a persistent, never-expiring toast for a wake they have already read. The unread field is already on the payload; filtering the toast list by it (or by !wake.unread as an extra dedupe) would avoid re-announcing read wakes without weakening the in-chat-toast behaviour.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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 dot right away; the poll restores the truth if another chat has one.
const markChatRead = useCallback(
async (chatId: string) => {
visibleChat.current = chatId;
setUnreadWakes(0);
// Opening a chat is what makes its work read, so the dot goes with it.
setUnreadWork((count) => Math.max(0, count - 1));
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 All @@ -99,8 +281,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, unreadWork }),
[open, setPanelOpen, openWith, openWithWatch, unreadWakes, unreadWork]
);

if (!hasAccess) {
Expand Down Expand Up @@ -129,8 +311,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