-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Expand file tree
/
Copy pathDashboardAgentChat.tsx
More file actions
449 lines (428 loc) · 18.2 KB
/
Copy pathDashboardAgentChat.tsx
File metadata and controls
449 lines (428 loc) · 18.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "@ai-sdk/react";
import type { dashboardAgent } from "@internal/dashboard-agent";
import type { AgentIntent, SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts";
import { useNavigate } from "@remix-run/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useToast } from "~/components/primitives/Toast";
import { AgentQuotaNotice, AgentUpgradeBlock } from "./AgentUpgradeGate";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
import { DashboardAgentHero } from "./DashboardAgentHero";
import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
import { createTranscriptOrder, orderTranscript } from "./message-order";
import { appendRunFilters, pendingNavigateIntents } from "./navigate-target";
import type { AgentPageContext } from "./page-context-types";
import { useAgentMessageQuota } from "./useAgentMessageQuota";
import { useTriggerUriResolver } from "./useTriggerUriResolver";
import { WatchChips, type WatchChip } from "./WatchChips";
// The persisted session for a chat: the session-scoped token plus the stream
// cursor. Resuming with `lastEventId` is what stops the agent's `.out` stream
// from replaying the previous turn.
export type DashboardAgentSession = {
publicAccessToken: string;
lastEventId?: string;
};
// Per-turn context for the agent. Matches the agent's clientDataSchema input.
export type DashboardAgentClientData = {
userId: string;
organizationId: string;
projectId?: string;
environmentId?: string;
currentPage?: string;
// What page the user is on, as facts rather than a path. Sent on create and
// on every turn, so the agent sees where the user is now — not where they
// were when the chat started.
pageContext?: AgentPageContext;
};
/**
* A single conversation. The panel mounts this with `key={chatId}`, so each
* chat gets its own transport constructed with its persisted session — the
* resume cursor flows in declaratively via the `sessions` option rather than
* an imperative setSession after the fact. A fresh chat passes no session and
* starts a new run on first send.
*/
export function DashboardAgentChat({
chatId,
initialMessages,
session,
clientData,
apiOrigin,
actionPath,
projectSlug,
environmentSlug,
currentPage,
pendingFirstMessage,
streaming,
prefill,
promotedPrompt,
watches,
pagePaths,
watchCard,
appendedMessage,
onWatchIntent,
onCancelWatch,
onTurnSettled,
onActivityChange,
}: {
chatId: string;
initialMessages: UIMessage[];
session: DashboardAgentSession | null;
clientData: DashboardAgentClientData;
apiOrigin: string;
actionPath: string;
projectSlug: string;
environmentSlug: string;
// Human label for the current page, for the context banner. The path the agent
// sees travels separately, in `clientData.currentPage`.
currentPage: string;
// Cold start: send this first message through the transport once on mount to
// trigger the turn. Undefined for head-started and resumed chats.
pendingFirstMessage?: string;
// Head start: the turn is already in flight, so hydrate the session as
// streaming so the transport resumes `session.out` instead of treating it as
// a settled session with nothing to reconnect to.
streaming?: boolean;
// Text dropped into the composer from outside (the launcher's `openWith`).
// `seq` makes each request distinct so the same text can be sent twice.
prefill?: { text: string; seq: number };
// The product-controlled promoted chip, from the feature flag. Only used for
// the suggested prompts on an empty chat.
promotedPrompt?: SuggestedPrompt;
// This chat's active watches, from the panel's history load.
watches: WatchChip[];
/** Host-resolved dashboard paths for settings-page footer actions. */
pagePaths?: Record<string, string>;
/** The ephemeral watch card, when one is open. Sits above the composer. */
watchCard?: React.ReactNode;
/**
* A message the SERVER appended outside a turn — the watch card's confirmation
* or one-shot result. It is already durable in the store; this puts it in the
* live transcript now instead of on the next open. `seq` makes each append
* distinct, so the effect applies it exactly once.
*/
appendedMessage?: { message: UIMessage; seq: number };
/**
* A card offered a watch. Every `watch` intent means the same thing — open the
* configuration card pre-filled with this spec — so the user reviews and
* submits it, and nothing is posted or persisted if they don't (§2.2).
*/
onWatchIntent?: (spec: WatchSpec) => void;
onCancelWatch: (watchId: string) => void;
/** A watch was created — tell the panel to re-read the chips. */
onTurnSettled: () => void;
/**
* Whether a turn is in flight, for the History list's row marker. Only this
* component knows — the turn status is `useChat`'s, with nothing server-side
* to read it back from.
*/
onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
}) {
const [input, setInput] = useState("");
const navigate = useNavigate();
const toast = useToast();
// Put requested text in the composer rather than sending it: a chat is already
// open, so the user gets to read and edit before it goes.
const prefilledSeq = useRef<number | undefined>(undefined);
useEffect(() => {
if (!prefill || prefilledSeq.current === prefill.seq) return;
prefilledSeq.current = prefill.seq;
setInput(prefill.text);
}, [prefill]);
const transport = useTriggerChatTransport<typeof dashboardAgent>({
task: "dashboard-agent",
baseURL: apiOrigin,
// New chats are created server-side (the `create` action owns the id and
// runs head start), so there's no client-driven head-start route here.
// Redirect only the `in`/append to the same-origin proxy, which mints +
// injects the delegated user token server-side. `baseURL` stays a string so
// `out` (the long-lived SSE) keeps the SDK's realtime-host routing — we
// never override it. The proxy forwards the same path on to the API.
fetch: (url, init, ctx) => {
if (ctx.endpoint !== "in") return globalThis.fetch(url, init);
const { pathname, search } = new URL(url);
return globalThis.fetch(`${actionPath}/in${pathname}${search}`, init);
},
clientData,
sessions: session
? {
[chatId]: {
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId,
// Head-started chats are mid-turn, so mark the session streaming to
// make the transport resume `session.out`. A settled session
// (history) stays false — its transcript loads from the store.
isStreaming: streaming ?? false,
},
}
: undefined,
startSession: async ({ chatId }) => {
const body = new FormData();
body.set("intent", "start");
body.set("chatId", chatId);
body.set("clientData", JSON.stringify(clientData));
const res = await fetch(actionPath, { method: "POST", body });
const data = (await res.json()) as { publicAccessToken?: string; error?: string };
if (!res.ok || !data.publicAccessToken) {
throw new Error(data.error ?? "The chat couldn't start.");
}
return { publicAccessToken: data.publicAccessToken };
},
accessToken: async ({ chatId }) => {
const body = new FormData();
body.set("intent", "token");
body.set("chatId", chatId);
const res = await fetch(actionPath, { method: "POST", body });
const data = (await res.json()) as { token?: string; error?: string };
if (!res.ok || !data.token) {
throw new Error(data.error ?? "Couldn't refresh the chat token.");
}
return data.token;
},
});
const {
messages: rawMessages,
setMessages,
sendMessage,
status,
stop: aiStop,
error,
clearError,
} = useChat({
id: chatId,
messages: initialMessages,
transport,
// Resume an existing/head-started session's stream. A cold-start chat has a
// session but nothing to resume yet — it sends its first message instead.
resume: !!session && !pendingFirstMessage,
});
// The transcript in stable order. The store's copy is the base; live arrivals
// go after it, and a turn the stream replays goes back into its own slot — so
// a message sent right after a remount can't land between older turns. See
// `message-order.ts`.
const orderRef = useRef(createTranscriptOrder(initialMessages));
const messages = orderTranscript(rawMessages, orderRef.current);
// The Free plan's message cap. Read here rather than in the panel so it counts
// this chat's live transcript — including the turn just sent. `unlimited` on
// any paid plan, and on any plan we can't identify.
const quota = useAgentMessageQuota({ actionPath, chatId, messages });
const atMessageCap = quota.kind === "reached";
const isStreaming = status === "streaming";
// A turn is in flight from submit until it settles. Deriving the indicator
// from status (rather than from what the last part happens to be) keeps it up
// through long tool calls, where the agent is busy but silent.
const activity: TurnActivity | null =
status === "submitted" ? "thinking" : status === "streaming" ? "working" : null;
// A server-appended block (the watch card's outcome) joins the live transcript
// in place. Applied once per `seq`: the append is already persisted, so
// replaying it would show the same confirmation twice.
const appendedSeq = useRef<number | undefined>(undefined);
useEffect(() => {
if (!appendedMessage || appendedSeq.current === appendedMessage.seq) return;
appendedSeq.current = appendedMessage.seq;
setMessages((current) =>
current.some((message) => message.id === appendedMessage.message.id)
? current
: [...current, appendedMessage.message]
);
}, [appendedMessage, setMessages]);
// Cold start: trigger the first turn by sending the pending message once.
const sentFirst = useRef(false);
useEffect(() => {
if (pendingFirstMessage && !sentFirst.current) {
sentFirst.current = true;
void sendMessage({ text: pendingFirstMessage });
}
}, [pendingFirstMessage, sendMessage]);
const submit = useCallback(
(text: string) => {
const trimmed = text.trim();
// The composer is gone at the cap, but a suggested prompt or a card's
// action can still call this — so the cap is enforced here, not just in
// what's rendered.
if (!trimmed || isStreaming || atMessageCap) return;
setInput("");
void sendMessage({ text: trimmed });
},
[isStreaming, atMessageCap, sendMessage]
);
// Re-send the last thing the user asked. The failed turn produced nothing, so
// sending the same text again is the whole retry — no server-side state to
// unwind.
const retry = useCallback(() => {
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
const text = lastUserMessage?.parts
?.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n")
.trim();
clearError();
if (text) void sendMessage({ text });
}, [messages, sendMessage, clearError]);
// Take the user where a `navigate` intent points. The target is a `trigger://`
// URI, so the path comes from the server (the route's `resolve` intent, which
// owns the environment scope the resolver needs); the intent's runs-list
// filters are applied on top. Same-origin, so this is a client-side navigation
// — the panel lives in the env layout and survives it.
// Sync facade over the same `resolve` action — evidence and card links render
// as raw URIs on first paint and become links once the server answers.
const resolveUri = useTriggerUriResolver(actionPath);
const goTo = useCallback(
async (intent: Extract<AgentIntent, { kind: "navigate" }>) => {
const body = new FormData();
body.set("intent", "resolve");
body.set("uri", intent.target);
try {
const res = await fetch(actionPath, { method: "POST", body });
const data = (await res.json()) as { path?: string };
if (!res.ok || !data.path) throw new Error(`Resolve failed (${res.status})`);
navigate(appendRunFilters(data.path, intent.filters));
} catch (error) {
console.error("Dashboard agent: failed to resolve a navigate target", error);
toast.error("Couldn't open that page.");
}
},
[actionPath, navigate, toast]
);
// What a card's action does. An `ask` goes back into the conversation as the
// user's own question.
//
// A `watch` does NOT: it opens the configuration card pre-filled with the spec
// the card offered. Every watch intent is treated this way, whatever offered it
// — so the user always sees what they are about to start, can change the window
// or the condition first, and an offer they walk away from leaves no trace. It
// used to post a visible "Watch this for me…" request and let the agent answer
// with schedule_watch; the card replaces that turn with 0 LLM.
//
// `propose_fix` is reserved and must never be executed.
const handleIntent = useCallback(
(intent: AgentIntent) => {
switch (intent.kind) {
case "ask":
submit(intent.prompt);
return;
case "watch":
onWatchIntent?.(intent.spec);
return;
case "navigate":
void goTo(intent);
return;
default:
console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`);
}
},
[submit, goTo, onWatchIntent]
);
// The `navigate_to` tool answers with an intent and the agent then narrates it
// in the past tense ("you're now on…"), so the panel has to actually move.
// Seeded with the loaded transcript before the first render, so opening a chat
// whose history contains a navigation never navigates on replay — only calls
// that land while this chat is open are honoured, once each.
const navigatedRef = useRef<Set<string> | null>(null);
if (navigatedRef.current === null) {
navigatedRef.current = new Set();
pendingNavigateIntents(initialMessages, navigatedRef.current);
}
useEffect(() => {
const pending = pendingNavigateIntents(messages, navigatedRef.current!);
// Only the last one matters — the earlier destinations are already history.
const target = pending.at(-1);
if (target?.kind === "navigate") void goTo(target);
}, [messages, goTo]);
const stop = useCallback(() => {
transport.stopGeneration(chatId);
aiStop();
}, [transport, chatId, aiStop]);
// Tell the panel to refresh its history list once a turn settles, so the new
// chat appears and titles/timestamps stay current.
const prevStatus = useRef(status);
useEffect(() => {
const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted";
const nowSettled = status === "ready" || status === "error";
if (wasInFlight && nowSettled) onTurnSettled();
prevStatus.current = status;
}, [status, onTurnSettled]);
// Report the turn's activity up, so the History list can mark this chat while
// it's working. Not cleared on unmount: opening History unmounts this chat but
// the turn carries on server-side, and it reports again when it remounts.
useEffect(() => {
onActivityChange?.(chatId, activity);
}, [chatId, activity, onActivityChange]);
return (
<>
{/* What this chat is watching, at the top of the panel: a watch outcome
arrives in the transcript unprompted, so the chips are what explain
where those messages will come from. */}
{/* Chips are an offer to cancel, so only live watches get one; the full
list still flows to the messages for the wake banner's tone. */}
<WatchChips
watches={watches.filter((watch) => watch.status === "active")}
onCancel={onCancelWatch}
/>
{/* A cold-start chat mounts with no messages and a first message about to
be sent, so the prompts would flash for a frame before the transcript
replaced them. Gate on that pending send. */}
{messages.length === 0 && !pendingFirstMessage ? (
// The blank state, minus the composer: this chat already has one docked
// below.
<DashboardAgentHero
onSelect={submit}
pageContext={clientData.pageContext}
promoted={promotedPrompt}
/>
) : (
<DashboardAgentMessages
messages={messages}
activity={activity}
error={error}
onRetry={retry}
onDismissError={clearError}
onIntent={handleIntent}
pagePaths={pagePaths}
watches={watches}
resolveUri={resolveUri}
/>
)}
{watchCard}
{/* The Free plan's message cap occupies the composer slot: at the cap the
composer is replaced by the upgrade block (a composer you can't send
from is worse than none), and under it the composer is followed by the
remaining count. The transcript above is untouched either way — the
conversation you already had stays readable. */}
{quota.kind === "reached" ? (
<AgentUpgradeBlock
limit={quota.limit}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
}
/>
) : (
<>
<DashboardAgentComposer
value={input}
onChange={setInput}
onSubmit={() => submit(input)}
onStop={stop}
isStreaming={isStreaming}
focusKey={prefill?.seq}
context={
<DashboardAgentContextBanner
projectSlug={projectSlug}
environmentSlug={environmentSlug}
currentPage={currentPage}
/>
}
/>
{quota.kind === "within" && (
<AgentQuotaNotice remaining={quota.remaining} limit={quota.limit} />
)}
</>
)}
</>
);
}