1- import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts" ;
2- import { useCallback , useMemo , useState } from "react" ;
1+ import type { SuggestedPrompt , WatchSpec } from "@internal/dashboard-agent-contracts" ;
2+ import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
33import {
44 ResizableHandle ,
55 ResizablePanel ,
66 ResizablePanelGroup ,
77} from "~/components/primitives/Resizable" ;
8+ import { useEnvironment } from "~/hooks/useEnvironment" ;
9+ import { useOrganization } from "~/hooks/useOrganizations" ;
10+ import { useProject } from "~/hooks/useProject" ;
811import { useShortcutKeys } from "~/hooks/useShortcutKeys" ;
912import { DashboardAgentPanel } from "./DashboardAgentPanel" ;
1013import { DashboardAgentProvider , TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher" ;
@@ -15,6 +18,16 @@ import {
1518 readAgentFullscreen ,
1619 writeAgentFullscreen ,
1720} from "./panel-layout" ;
21+ import {
22+ showWatchWakesSummaryToast ,
23+ showWatchWakeToast ,
24+ WAKE_TOAST_MAX_INDIVIDUAL ,
25+ type WatchWake ,
26+ } from "./WatchWakeToast" ;
27+
28+ // How often the closed panel asks whether a watch woke a chat. A wake is worth
29+ // noticing within a minute, and the count is one indexed query.
30+ const UNREAD_POLL_INTERVAL_MS = 60_000 ;
1831
1932/**
2033 * Mounts the dashboard agent in the env layout. Renders the page content
@@ -38,7 +51,18 @@ export function DashboardAgent({
3851 // The product-controlled promoted prompt chip, from the feature flag.
3952 promotedPrompt ?: SuggestedPrompt ;
4053} ) {
54+ const organization = useOrganization ( ) ;
55+ const project = useProject ( ) ;
56+ const environment = useEnvironment ( ) ;
57+ const actionPath = `/resources/orgs/${ organization . slug } /projects/${ project . slug } /env/${ environment . slug } /dashboard-agent` ;
58+
4159 const [ open , setOpen ] = useState ( false ) ;
60+ const [ unreadWakes , setUnreadWakes ] = useState ( 0 ) ;
61+ // Wakes already toasted this session. Session-scoped on purpose: a wake that
62+ // arrived overnight deserves the toast on the first poll after a reload, but a
63+ // wake the user has already been shown (and maybe dismissed) must not come
64+ // back every 60s while the chat stays unread.
65+ const toastedWakes = useRef ( new Set < string > ( ) ) ;
4266 // The side panel is the default; someone who last worked fullscreen gets
4367 // fullscreen back. Read lazily so SSR always renders the side panel.
4468 const [ fullscreen , setFullscreen ] = useState ( readAgentFullscreen ) ;
@@ -57,14 +81,39 @@ export function DashboardAgent({
5781 const [ requestedMessage , setRequestedMessage ] = useState <
5882 { text : string ; seq : number } | undefined
5983 > ( undefined ) ;
84+ // A specific chat to open, from a wake toast. `seq` so the same chat can be
85+ // asked for twice (a second wake in a chat the user has already left).
86+ const [ openChatRequest , setOpenChatRequest ] = useState <
87+ { chatId : string ; seq : number } | undefined
88+ > ( undefined ) ;
89+ // A watch card asked for by a `Watch…` entry (§2.1). A card is not a message,
90+ // so it travels on its own channel: the panel opens it pre-filled, and nothing
91+ // reaches the transcript unless the user submits it.
92+ const [ watchRequest , setWatchRequest ] = useState < { spec : WatchSpec ; seq : number } | undefined > (
93+ undefined
94+ ) ;
6095
6196 // Closing drops any pending request, so reopening the panel later doesn't
6297 // replay text the user has moved on from.
6398 const setPanelOpen = useCallback ( ( next : boolean ) => {
6499 setOpen ( next ) ;
65- // The panel unmounts on close, so a stale request would re-apply on the next
66- // open instead of restoring the last chat.
67- if ( ! next ) setRequestedMessage ( undefined ) ;
100+ // Closing drops both pending requests: the panel unmounts, so a stale one
101+ // would re-apply on the next open instead of restoring the last chat.
102+ if ( ! next ) {
103+ setRequestedMessage ( undefined ) ;
104+ setOpenChatRequest ( undefined ) ;
105+ // An abandoned card leaves no trace (§2.2) — including no pending request
106+ // that would re-open it the next time the panel is.
107+ setWatchRequest ( undefined ) ;
108+ }
109+ } , [ ] ) ;
110+
111+ // Open the panel on the chat a wake happened in. Without the chat id the panel
112+ // would just restore whatever it had open last, which is rarely the one the
113+ // toast is about.
114+ const openChat = useCallback ( ( chatId : string ) => {
115+ setOpen ( true ) ;
116+ setOpenChatRequest ( ( current ) => ( { chatId, seq : ( current ?. seq ?? 0 ) + 1 } ) ) ;
68117 } , [ ] ) ;
69118
70119 const openWith = useCallback ( ( text : string ) => {
@@ -74,6 +123,72 @@ export function DashboardAgent({
74123 setRequestedMessage ( ( current ) => ( { text : trimmed , seq : ( current ?. seq ?? 0 ) + 1 } ) ) ;
75124 } , [ ] ) ;
76125
126+ const openWithWatch = useCallback ( ( spec : WatchSpec ) => {
127+ setOpen ( true ) ;
128+ setWatchRequest ( ( current ) => ( { spec, seq : ( current ?. seq ?? 0 ) + 1 } ) ) ;
129+ } , [ ] ) ;
130+
131+ // The dot's poll, and the toast's. Runs only while the panel is CLOSED — an
132+ // open panel shows the wake in the transcript, so polling then would only race
133+ // the read marker. Both the interval and the on-close refresh come from this
134+ // effect re-running on `open`.
135+ useEffect ( ( ) => {
136+ if ( ! hasAccess || open ) return ;
137+
138+ let cancelled = false ;
139+ const load = async ( ) => {
140+ try {
141+ const res = await fetch ( `${ actionPath } ?unread=1` ) ;
142+ if ( ! res . ok ) return ;
143+ const data = ( await res . json ( ) ) as { unreadWakes ?: number ; wakes ?: WatchWake [ ] } ;
144+ if ( cancelled ) return ;
145+ setUnreadWakes ( data . unreadWakes ?? 0 ) ;
146+
147+ const fresh = ( data . wakes ?? [ ] ) . filter ( ( wake ) => ! toastedWakes . current . has ( wake . watchId ) ) ;
148+ for ( const wake of fresh ) toastedWakes . current . add ( wake . watchId ) ;
149+
150+ // A burst gets one summary toast: a stack of persistent toasts is a wall,
151+ // not a notification.
152+ if ( fresh . length > WAKE_TOAST_MAX_INDIVIDUAL ) {
153+ showWatchWakesSummaryToast ( fresh . length , ( ) => setPanelOpen ( true ) ) ;
154+ } else {
155+ // Oldest first, so the newest wake ends up nearest the user.
156+ for ( const wake of [ ...fresh ] . reverse ( ) ) {
157+ showWatchWakeToast ( wake , openChat ) ;
158+ }
159+ }
160+ } catch {
161+ // Offline or a hiccup — leave the dot as it is and try again next tick.
162+ }
163+ } ;
164+
165+ void load ( ) ;
166+ const interval = window . setInterval ( load , UNREAD_POLL_INTERVAL_MS ) ;
167+ return ( ) => {
168+ cancelled = true ;
169+ window . clearInterval ( interval ) ;
170+ } ;
171+ } , [ hasAccess , open , actionPath , setPanelOpen , openChat ] ) ;
172+
173+ // A chat the user is now looking at has no unread wakes. Zeroes the dot right
174+ // away (the poll restores the truth on close if another chat still has one) and
175+ // persists the read marker for the chat that's actually visible.
176+ const markChatRead = useCallback (
177+ async ( chatId : string ) => {
178+ setUnreadWakes ( 0 ) ;
179+ const body = new FormData ( ) ;
180+ body . set ( "intent" , "read" ) ;
181+ body . set ( "chatId" , chatId ) ;
182+ try {
183+ await fetch ( actionPath , { method : "POST" , body } ) ;
184+ } catch {
185+ // Not worth surfacing: the marker is caught up the next time the chat is
186+ // opened.
187+ }
188+ } ,
189+ [ actionPath ]
190+ ) ;
191+
77192 // ⌘J is contextual: closed → open the panel (the composer focuses itself, so
78193 // the keystroke lands you in the text field); open → start a new chat.
79194 // Closing is Esc or the header's ×, never ⌘J.
@@ -96,8 +211,8 @@ export function DashboardAgent({
96211 useDashboardAgentOpenRequests ( { enabled : hasAccess , openWith, setOpen : setPanelOpen } ) ;
97212
98213 const context = useMemo (
99- ( ) => ( { open, setOpen : setPanelOpen , openWith } ) ,
100- [ open , setPanelOpen , openWith ]
214+ ( ) => ( { open, setOpen : setPanelOpen , openWith, openWithWatch , unreadWakes } ) ,
215+ [ open , setPanelOpen , openWith , openWithWatch , unreadWakes ]
101216 ) ;
102217
103218 if ( ! hasAccess ) {
@@ -130,8 +245,11 @@ export function DashboardAgent({
130245 < DashboardAgentPanel
131246 onClose = { ( ) => setPanelOpen ( false ) }
132247 requestedMessage = { requestedMessage }
248+ openChatRequest = { openChatRequest }
249+ watchRequest = { watchRequest }
133250 newChatSeq = { newChatSeq }
134251 promotedPrompt = { promotedPrompt }
252+ onChatRead = { markChatRead }
135253 isFullscreen = { fullscreen }
136254 onToggleFullscreen = { toggleFullscreen }
137255 />
0 commit comments