-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): dashboard agent — Watch #4525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/dashboard-agent-ui
Are you sure you want to change the base?
Changes from all commits
89b7522
b66e3ca
94be10b
847071f
e08a8de
4697e90
4f26e67
25c8ccf
894a722
98826ee
baa3971
46e72f3
ed8d649
81e177a
2d40177
dabe0c6
7c929fe
b5c3788
7d7adeb
57f438a
fa60e7d
274141c
6c04097
379b5f8
70d44bc
2f27582
19c959c
fabd321
dca8646
45c09ba
f2d153f
2f75b20
0cb1962
5838b6a
feadd6f
b71a752
f5e8580
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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 { | ||
|
|
@@ -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); | ||
| // 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); | ||
|
|
||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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, | ||
|
|
@@ -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) { | ||
|
|
@@ -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} | ||
| /> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
initialUnreadWakesseedsuseStateonly 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 newactionPath. Thewatchingflag re-evaluates via its own effect (it depends oninitialUnreadWakes/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 wheninitialUnreadWakeschanges would close it.Was this helpful? React with 👍 or 👎 to provide feedback.