+
{block.title ? (
-
+
) : null}
-
+
{state.status === "loading" ? (
) : state.status === "error" ? (
@@ -129,6 +174,7 @@ export function AgentChart({ block }: { block: ChartBlock }) {
/>
)}
-
+
+
);
}
diff --git a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx
new file mode 100644
index 00000000000..4b795edde9e
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx
@@ -0,0 +1,58 @@
+import { Link } from "@remix-run/react";
+import { LinkButton } from "~/components/primitives/Buttons";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { cn } from "~/utils/cn";
+import { v3BillingPath } from "~/utils/pathBuilder";
+import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity";
+
+// Matches the composer's outer geometry so the replacement lands in the same place.
+const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pb-3 pt-1";
+
+export function AgentUpgradeBlock({
+ limit,
+ context,
+}: {
+ limit: number;
+ context?: React.ReactNode;
+}) {
+ const organization = useOrganization();
+
+ return (
+
+ {context}
+
+
+
+
+ Upgrade to unlock {ASK_AGENT_LABEL}
+
+
+
+ You've used all {limit} messages included on the Free plan. Your chats stay here to read.
+
+
+ Upgrade
+
+
+
+ );
+}
+
+export function AgentQuotaNotice({ remaining, limit }: { remaining: number; limit: number }) {
+ const organization = useOrganization();
+
+ return (
+
+
+ {remaining} of {limit} free messages left
+
+ ·
+
+ Upgrade
+
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx b/apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx
new file mode 100644
index 00000000000..f740758255e
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx
@@ -0,0 +1,41 @@
+import { Button } from "~/components/primitives/Buttons";
+import { SimpleTooltip } from "~/components/primitives/Tooltip";
+import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity";
+import { requestDashboardAgent, useDashboardAgentAvailable } from "./dashboardAgentOpenRequest";
+
+// Goes through the open-request bridge rather than the provider context, so it works
+// on pages above the environment layout.
+export function AskAgentButton({
+ prompt,
+ label = ASK_AGENT_LABEL,
+ iconOnly = false,
+ variant = "small-menu-item",
+ className,
+ fallback = null,
+}: {
+ prompt?: string;
+ label?: string;
+ iconOnly?: boolean;
+ variant?: "small-menu-item" | "secondary/small" | "primary/small";
+ className?: string;
+ fallback?: React.ReactNode;
+}) {
+ const available = useDashboardAgentAvailable();
+ if (!available) return <>{fallback}>;
+
+ const button = (
+
+ );
+
+ return iconOnly ?
: button;
+}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx
index 2796c8516df..e932c4e79f5 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx
@@ -1,53 +1,143 @@
-import { useState } from "react";
+import type { SuggestedPrompt } 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 { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { DashboardAgentPanel } from "./DashboardAgentPanel";
-import { DashboardAgentProvider } from "./dashboardAgentLauncher";
-
-/**
- * Mounts the dashboard agent in the env layout. Renders the page content
- * (`children` = the route Outlet) and shares the open/close state via context so
- * the page-header launcher (`DashboardAgentLauncher`) can toggle it. When open it
- * splits the layout into a resizable content + agent panel, `autosaveId` persists
- * the width.
- *
- * `hasAccess` is resolved server-side in the env layout loader
- * (`canAccessDashboardAgent`); when false we render the content untouched and
- * never expose the context, so the launcher stays hidden. The resource routes
- * enforce the same check server-side.
- */
+import {
+ DashboardAgentProvider,
+ LEGACY_ASK_AI_SHORTCUT,
+ TOGGLE_PANEL_SHORTCUT,
+} from "./dashboardAgentLauncher";
+import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest";
+import {
+ agentHiddenContentClassName,
+ agentTakeoverClassName,
+ readAgentFullscreen,
+ writeAgentFullscreen,
+} from "./panel-layout";
+
+/** `hasAccess` is a UI gate only; the resource routes enforce the same check server-side. */
export function DashboardAgent({
children,
hasAccess = false,
+ promotedPrompt,
}: {
children: React.ReactNode;
hasAccess?: boolean;
+ promotedPrompt?: SuggestedPrompt;
}) {
const [open, setOpen] = useState(false);
+ // Read lazily so SSR always renders the side panel.
+ const [fullscreen, setFullscreen] = useState(readAgentFullscreen);
+
+ const toggleFullscreen = useCallback(() => {
+ setFullscreen((current) => {
+ writeAgentFullscreen(!current);
+ return !current;
+ });
+ }, []);
+
+ // Pathname only: filter and search-param changes must keep fullscreen.
+ const { pathname } = useLocation();
+ const previousPathname = useRef(pathname);
+ useEffect(() => {
+ if (previousPathname.current === pathname) return;
+ previousPathname.current = pathname;
+ setFullscreen((current) => {
+ if (current) writeAgentFullscreen(false);
+ return false;
+ });
+ }, [pathname]);
+ const [newChatSeq, setNewChatSeq] = useState(0);
+ const [requestedMessage, setRequestedMessage] = useState<
+ { text: string; 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) {
+ setFullscreen(false);
+ writeAgentFullscreen(false);
+ setRequestedMessage(undefined);
+ }
+ }, []);
+
+ const openWith = useCallback((text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed) return;
+ setOpen(true);
+ setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
+ }, []);
+
+ // ⌘J is contextual: closed opens the panel, open starts a new chat. It never closes.
+ useShortcutKeys({
+ shortcut: TOGGLE_PANEL_SHORTCUT,
+ action: () => {
+ if (!open) {
+ setPanelOpen(true);
+ } else {
+ setNewChatSeq((seq) => seq + 1);
+ }
+ },
+ disabled: !hasAccess,
+ enabledOnInputElements: true,
+ });
+
+ useShortcutKeys({
+ shortcut: LEGACY_ASK_AI_SHORTCUT,
+ action: () => setPanelOpen(true),
+ disabled: !hasAccess,
+ enabledOnInputElements: true,
+ });
+
+ useDashboardAgentOpenRequests({ enabled: hasAccess, openWith, setOpen: setPanelOpen });
+
+ const context = useMemo(
+ () => ({ open, setOpen: setPanelOpen, openWith }),
+ [open, setPanelOpen, openWith]
+ );
if (!hasAccess) {
return
{children}
;
}
return (
-
+
{open ? (
-
-
- {children}
-
-
-
- setOpen(false)} />
-
-
+ // `relative` is the takeover's containing block.
+
+
+
+ {children}
+
+
+
+
+ setPanelOpen(false)}
+ requestedMessage={requestedMessage}
+ newChatSeq={newChatSeq}
+ promotedPrompt={promotedPrompt}
+ isFullscreen={fullscreen}
+ onToggleFullscreen={toggleFullscreen}
+ />
+
+
+
+
) : (
{children}
)}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
index e6662ca8494..526dedde342 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
@@ -1,37 +1,46 @@
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "@ai-sdk/react";
import type { dashboardAgent } from "@internal/dashboard-agent";
+import type { AgentIntent, SuggestedPrompt } 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 { DashboardAgentMessages } from "./DashboardAgentMessages";
-import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
+import { DashboardAgentHero } from "./DashboardAgentHero";
+import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
+import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
+import { createTranscriptOrder, orderTranscript } from "./message-order";
+import { appendRunFilters } from "./navigate-target";
+import { pendingNavigateIntents } from "./pending-intents";
+import type { AgentPageContext } from "./page-context-types";
+import {
+ fetchChatTranscript,
+ hasOpenInvestigation,
+ pollSettledTranscript,
+} from "./settled-transcript";
+import { useAgentMessageQuota } from "./useAgentMessageQuota";
+import { useTriggerUriResolver } from "./useTriggerUriResolver";
-// 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.
+// Resuming with `lastEventId` stops the `.out` stream replaying the previous turn.
export type DashboardAgentSession = {
publicAccessToken: string;
lastEventId?: string;
};
-// Per-turn context for the agent. Matches the agent's clientDataSchema input.
+// Matches the agent's clientDataSchema input.
export type DashboardAgentClientData = {
userId: string;
organizationId: string;
projectId?: string;
environmentId?: string;
currentPage?: string;
+ 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.
- */
+/** Mounted with `key={chatId}`: the resume cursor arrives via `sessions`, not setSession. */
export function DashboardAgentChat({
chatId,
initialMessages,
@@ -44,7 +53,11 @@ export function DashboardAgentChat({
currentPage,
pendingFirstMessage,
streaming,
+ prefill,
+ promotedPrompt,
+ pagePaths,
onTurnSettled,
+ onActivityChange,
}: {
chatId: string;
initialMessages: UIMessage[];
@@ -54,31 +67,47 @@ export function DashboardAgentChat({
actionPath: string;
projectSlug: string;
environmentSlug: string;
+ // Display label only; the path the agent sees is `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.
+ // 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;
+ // `seq` makes each request distinct so the same text can be sent twice.
+ prefill?: { text: string; seq: number };
+ promotedPrompt?: SuggestedPrompt;
+ pagePaths?: Record;
onTurnSettled: () => void;
+ onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
}) {
const [input, setInput] = useState("");
+ const navigate = useNavigate();
+ const toast = useToast();
+
+ const prefilledSeq = useRef(undefined);
+ useEffect(() => {
+ if (!prefill || prefilledSeq.current === prefill.seq) return;
+ prefilledSeq.current = prefill.seq;
+ setInput(prefill.text);
+ }, [prefill]);
const transport = useTriggerChatTransport({
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) => {
+ // Only `in` goes through the same-origin proxy, which injects the delegated user
+ // token server-side. `baseURL` stays a string so `out` keeps the SDK's realtime routing.
+ fetch: async (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);
+ const res = await globalThis.fetch(`${actionPath}/in${pathname}${search}`, init);
+ // A refused message never succeeds on a retry, so it surfaces as the turn's error.
+ if (res.status === 413) {
+ const data = (await res
+ .clone()
+ .json()
+ .catch(() => null)) as { error?: string } | null;
+ throw new Error(data?.error ?? MESSAGE_TOO_LARGE_ERROR);
+ }
+ return res;
},
clientData,
sessions: session
@@ -86,9 +115,7 @@ export function DashboardAgentChat({
[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.
+ // Mid-turn chats must be marked streaming or the transport won't resume `session.out`.
isStreaming: streaming ?? false,
},
}
@@ -119,24 +146,32 @@ export function DashboardAgentChat({
});
const {
- messages,
+ 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,
});
+ const orderRef = useRef(createTranscriptOrder(initialMessages));
+ const messages = orderTranscript(rawMessages, orderRef.current);
+
+ // Counted here, not in the panel, so it includes the turn just sent.
+ const quota = useAgentMessageQuota({ actionPath, chatId, messages });
+ const atMessageCap = quota.kind === "reached";
+
const isStreaming = status === "streaming";
- const isThinking = status === "submitted";
+ // From status, not the last part: the indicator must stay up through silent tool calls.
+ const activity: TurnActivity | null =
+ status === "submitted" ? "thinking" : status === "streaming" ? "working" : null;
- // Cold start: trigger the first turn by sending the pending message once.
const sentFirst = useRef(false);
useEffect(() => {
if (pendingFirstMessage && !sentFirst.current) {
@@ -148,47 +183,160 @@ export function DashboardAgentChat({
const submit = useCallback(
(text: string) => {
const trimmed = text.trim();
- if (!trimmed || isStreaming) return;
+ // Suggested prompts and card actions bypass the composer, so the cap is enforced here too.
+ if (!trimmed || isStreaming || atMessageCap) return;
setInput("");
void sendMessage({ text: trimmed });
},
- [isStreaming, sendMessage]
+ [isStreaming, atMessageCap, sendMessage]
+ );
+
+ 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]);
+
+ const resolveUri = useTriggerUriResolver(actionPath);
+
+ // `trigger://` targets resolve server-side: the server owns the environment scope.
+ const goTo = useCallback(
+ async (intent: Extract) => {
+ 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]
);
+ // `propose_fix` is reserved and must never be executed.
+ const handleIntent = useCallback(
+ (intent: AgentIntent) => {
+ switch (intent.kind) {
+ case "ask":
+ submit(intent.prompt);
+ return;
+ case "navigate":
+ void goTo(intent);
+ return;
+ default:
+ console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`);
+ }
+ },
+ [submit, goTo]
+ );
+
+ // Seeded from the loaded transcript before first render, so history never re-navigates.
+ const navigatedRef = useRef | null>(null);
+ if (navigatedRef.current === null) {
+ navigatedRef.current = new Set();
+ pendingNavigateIntents(initialMessages, navigatedRef.current);
+ }
+ useEffect(() => {
+ const pending = pendingNavigateIntents(messages, navigatedRef.current!);
+ const target = pending.at(-1);
+ if (target) 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.
+ // Read by the settle effect, which must not re-run when the transcript changes.
+ const messagesRef = useRef(messages);
+ messagesRef.current = messages;
+
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]);
+ if (!wasInFlight || !nowSettled) return;
+
+ onTurnSettled();
+ // The terminal card is written to the chat row after the stream closes, so this
+ // mounted panel would otherwise keep showing the last `in_progress` revision.
+ if (!hasOpenInvestigation(messagesRef.current)) return;
+ void pollSettledTranscript({
+ fetchTranscript: () => fetchChatTranscript(actionPath, chatId),
+ apply: (merge) => setMessages((current) => merge(current)),
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
+ });
+ }, [status, onTurnSettled, actionPath, chatId, setMessages]);
+
+ // Not cleared on unmount: the turn carries on server-side and reports again on remount.
+ useEffect(() => {
+ onActivityChange?.(chatId, activity);
+ }, [chatId, activity, onActivityChange]);
return (
<>
-
- {messages.length === 0 ? (
-
+ {messages.length === 0 && !pendingFirstMessage ? (
+
+ ) : (
+
+ )}
+ {quota.kind === "reached" ? (
+
+ }
+ />
) : (
-
+ <>
+ submit(input)}
+ onStop={stop}
+ isStreaming={isStreaming}
+ focusKey={prefill?.seq}
+ context={
+
+ }
+ />
+ {quota.kind === "within" && (
+
+ )}
+ >
)}
- submit(input)}
- onStop={stop}
- isStreaming={isStreaming}
- />
>
);
}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx
index 308a87ebab1..5b238d8ffd4 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx
@@ -1,7 +1,10 @@
-import { PaperAirplaneIcon, StopIcon } from "@heroicons/react/20/solid";
-import { useRef } from "react";
+import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid";
+import { useEffect, useRef } from "react";
import { Button } from "~/components/primitives/Buttons";
import { cn } from "~/utils/cn";
+import { MAX_MESSAGE_CHARS, MESSAGE_CHARS_WARN_AT } from "./message-limits";
+
+export type DashboardAgentComposerLayout = "docked" | "hero";
export function DashboardAgentComposer({
value,
@@ -9,50 +12,124 @@ export function DashboardAgentComposer({
onSubmit,
onStop,
isStreaming,
+ focusKey,
+ context,
+ layout = "docked",
+ autoFocus = true,
+ placeholderSuggestion,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
isStreaming: boolean;
+ // Bump to move focus back to the textarea.
+ focusKey?: string | number;
+ context?: React.ReactNode;
+ layout?: DashboardAgentComposerLayout;
+ autoFocus?: boolean;
+ // Shown as the placeholder while the field is empty. Tab accepts it as editable
+ // text; it is never sent on its own.
+ placeholderSuggestion?: string;
}) {
const ref = useRef(null);
+ useEffect(() => {
+ const el = ref.current;
+ if (!el || !autoFocus) return;
+ el.focus();
+ el.setSelectionRange(el.value.length, el.value.length);
+ }, [focusKey, autoFocus]);
+
+ const isHero = layout === "hero";
+
+ const sendButton = isStreaming ? (
+ }
+ />
+ ) : (
+ }
+ />
+ );
+
return (
-
-
-
+
+ {isHero ? null : context}
+
+ {/* Only near the limit: a normal message never sees a counter. */}
+ {value.length >= MESSAGE_CHARS_WARN_AT ? (
+
= MAX_MESSAGE_CHARS ? "text-error" : "text-text-dimmed"
+ )}
+ aria-live="polite"
+ >
+ {value.length} / {MAX_MESSAGE_CHARS}
+
+ ) : null}
);
}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentContextBanner.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentContextBanner.tsx
index d7a48af850f..2c6addb7a6f 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentContextBanner.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentContextBanner.tsx
@@ -1,20 +1,32 @@
+import { cn } from "~/utils/cn";
+
export function DashboardAgentContextBanner({
projectSlug,
environmentSlug,
currentPage,
+ className,
}: {
projectSlug: string;
environmentSlug: string;
+ // A human label from `page-label.ts`, not a path.
currentPage: string;
+ className?: string;
}) {
+ const path = `${projectSlug} / ${environmentSlug} / ${currentPage}`;
return (
-
+
Context:
{projectSlug}
- /
+ /
{environmentSlug}
- /
- {currentPage}
+ /
+ {currentPage}
);
}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
index 115710139e7..02c3cedb2c2 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
@@ -1,28 +1,44 @@
-import { useCallback, useState } from "react";
+import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
+import { useCallback, useMemo, useState } from "react";
import { DashboardAgentComposer } from "./DashboardAgentComposer";
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
-import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
+import { DashboardAgentHero } from "./DashboardAgentHero";
+import type { AgentPageContext } from "./page-context-types";
+import { readDismissedPromptIds, resolveSuggestedPromptsBySlot } from "./suggested-prompts";
-/**
- * The new-chat "draft" state: suggested prompts + composer with no transport
- * mounted and no chat id yet. The chat id is server-owned, so the first send
- * goes to the panel's `create` call, which generates the id and returns it;
- * only then does the real `DashboardAgentChat` mount. The client never invents
- * a chat id.
- */
+// Chat ids are server-owned: the first send goes to the panel's `create` call, which
+// returns the id, and only then does `DashboardAgentChat` mount.
export function DashboardAgentDraft({
onSubmit,
projectSlug,
environmentSlug,
currentPage,
+ pageContext,
+ promotedPrompt,
}: {
onSubmit: (text: string) => void;
projectSlug: string;
environmentSlug: string;
currentPage: string;
+ pageContext?: AgentPageContext;
+ promotedPrompt?: SuggestedPrompt;
}) {
const [input, setInput] = useState("");
+ // Same resolution the hero's buttons use, so the placeholder matches the first button.
+ const [dismissedIds] = useState(readDismissedPromptIds);
+ const placeholderSuggestion = useMemo(
+ () =>
+ resolveSuggestedPromptsBySlot(
+ pageContext ?? { page: { kind: "other", path: "" }, signals: [] },
+ {
+ promoted: promotedPrompt,
+ dismissedIds,
+ }
+ )[0]?.prompt.prompt,
+ [pageContext, promotedPrompt, dismissedIds]
+ );
+
const submit = useCallback(
(text: string) => {
const trimmed = text.trim();
@@ -34,20 +50,30 @@ export function DashboardAgentDraft({
);
return (
- <>
-
-
-
submit(input)}
- onStop={() => {}}
- isStreaming={false}
- />
- >
+
+ submit(input)}
+ onStop={() => {}}
+ isStreaming={false}
+ placeholderSuggestion={placeholderSuggestion}
+ context={
+
+ }
+ />
+
+ }
+ />
);
}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx
index 11d486bc1d3..7af9556bb3d 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx
@@ -1,57 +1,130 @@
-import { ClockIcon, PencilSquareIcon, XMarkIcon } from "@heroicons/react/20/solid";
-import { cn } from "~/utils/cn";
+import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
+import { useState } from "react";
+import { CrossIcon } from "~/assets/icons/CrossIcon";
+import { PlusIcon } from "~/assets/icons/PlusIcon";
+import { Button } from "~/components/primitives/Buttons";
+import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
+import { ShortcutKey } from "~/components/primitives/ShortcutKey";
+import type { Shortcut } from "~/hooks/useShortcutKeys";
+import { DashboardAgentHistoryMenu, type DashboardAgentChat } from "./DashboardAgentHistory";
+
+// Display only. The key is registered once, in `DashboardAgent`; registering it
+// anywhere else makes the keystroke fire twice.
+export const NEW_CHAT_SHORTCUT: Shortcut = {
+ modifiers: ["mod"],
+ key: "j",
+ enabledOnInputElements: true,
+};
export function DashboardAgentHeader({
- view,
+ title,
+ chats,
+ currentChatId,
+ thinkingChatId,
onNewChat,
- onToggleHistory,
+ showNewChat,
+ onOpenHistory,
+ onSelectChat,
+ onDeleteChat,
+ onToggleFullscreen,
+ isFullscreen,
onClose,
}: {
- view: "chat" | "history";
+ title: string;
+ chats: DashboardAgentChat[];
+ currentChatId: string;
+ thinkingChatId?: string | null;
onNewChat: () => void;
- onToggleHistory: () => void;
+ showNewChat: boolean;
+ onOpenHistory: () => void;
+ onSelectChat: (chatId: string) => void;
+ onDeleteChat: (chatId: string) => void;
+ onToggleFullscreen: () => void;
+ isFullscreen: boolean;
onClose: () => void;
}) {
+ const [isHistoryOpen, setHistoryOpen] = useState(false);
+
return (
-
-
Chat
-
-
-
+ {
+ setHistoryOpen(open);
+ if (open) onOpenHistory();
+ }}
+ >
+
+ {title}
+
+
+ {
+ setHistoryOpen(false);
+ onSelectChat(chatId);
+ }}
+ onDelete={onDeleteChat}
+ />
+
+
+
+
+ {showNewChat && (
+
);
}
-
-function IconButton({
- label,
- icon: Icon,
- onClick,
- active,
-}: {
- label: string;
- icon: React.ComponentType<{ className?: string }>;
- onClick: () => void;
- active?: boolean;
-}) {
- return (
-
-
-
- );
-}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHero.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentHero.tsx
new file mode 100644
index 00000000000..a4670aef6c6
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHero.tsx
@@ -0,0 +1,45 @@
+import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts";
+import { BetaBadge } from "~/components/FeatureBadges";
+import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix";
+import { Header1 } from "~/components/primitives/Headers";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
+
+export function DashboardAgentHero({
+ onSelect,
+ pageContext,
+ promoted,
+ dismissedIds,
+ composer,
+}: {
+ /** Receives the prompt text to send, not the button label. */
+ onSelect: (prompt: string) => void;
+ pageContext?: AgentPageContext;
+ promoted?: SuggestedPrompt;
+ dismissedIds?: string[];
+ composer?: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+ Ask Trigger
+
+
+
+ About your runs, errors, or how Trigger.dev works.
+
+
+ {composer}
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx
index bc40327447b..43407fed164 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx
@@ -1,81 +1,149 @@
-import { PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
-import { DateTime } from "~/components/primitives/DateTime";
+import { MagnifyingGlassIcon, TrashIcon } from "@heroicons/react/20/solid";
+import { formatDurationMilliseconds } from "@trigger.dev/core/v3/utils/durations";
+import { useState } from "react";
+import { Button } from "~/components/primitives/Buttons";
+import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
+import { FormButtons } from "~/components/primitives/FormButtons";
import { Paragraph } from "~/components/primitives/Paragraph";
-import { cn } from "~/utils/cn";
+import { AgentSpinner } from "~/components/primitives/Spinner";
+import { AgentList, AgentListRow, AgentListRowAction } from "./list-row";
// Date fields arrive as strings over the loader's JSON.
export type DashboardAgentChat = {
id: string;
title: string;
lastMessageAt: string | null;
- updatedAt: string;
+ hasOpenInvestigation?: boolean;
};
-export function DashboardAgentHistory({
+type ChatProcess = "thinking" | "investigating";
+
+const PROCESS_LABELS: Record
= {
+ thinking: "Agent is thinking",
+ investigating: "Investigation in progress",
+};
+
+function chatProcess(chat: DashboardAgentChat, isThinking: boolean): ChatProcess | null {
+ if (isThinking) return "thinking";
+ if (chat.hasOpenInvestigation) return "investigating";
+ return null;
+}
+
+function ProcessIcon({ process }: { process: ChatProcess }) {
+ const label = PROCESS_LABELS[process];
+ // No tooltip trigger here: the row is a button, and this would nest one inside it.
+ return (
+
+ {process === "investigating" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+// Weeks are the coarsest unit: months render as "1.8mo" for eight weeks.
+const AGE_UNITS = ["w", "d", "h", "m"] as const;
+
+export function chatAge(lastMessageAt: string, now: number = Date.now()): string | undefined {
+ const at = Date.parse(lastMessageAt);
+ if (Number.isNaN(at)) return undefined;
+ const elapsed = Math.max(0, now - at);
+ if (elapsed < 60_000) return "now";
+ return formatDurationMilliseconds(elapsed, {
+ style: "short",
+ maxUnits: 1,
+ maxDecimalPoints: 0,
+ units: [...AGE_UNITS],
+ });
+}
+
+export function DashboardAgentHistoryMenu({
chats,
currentChatId,
+ thinkingChatId,
onSelect,
- onNewChat,
onDelete,
}: {
chats: DashboardAgentChat[];
currentChatId: string;
+ thinkingChatId?: string | null;
onSelect: (chatId: string) => void;
- onNewChat: () => void;
onDelete: (chatId: string) => void;
}) {
- return (
-
-
-
-
- New chat
-
+ const [pendingDelete, setPendingDelete] = useState
(null);
+ const now = Date.now();
+ return (
+ <>
+
{chats.length === 0 ? (
-
+
No previous chats yet.
) : (
-
- {chats.map((chat) => (
- -
-
- onSelect(chat.id)}
- className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left outline-hidden focus-custom"
- >
- {chat.title}
- {chat.lastMessageAt && (
-
-
-
- )}
-
- onDelete(chat.id)}
- aria-label="Delete chat"
- className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity hover:text-error group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 focus-custom"
- >
-
-
-
-
- ))}
-
+
+ {chats.map((chat) => {
+ const process = chatProcess(chat, chat.id === thinkingChatId);
+ const age = chat.lastMessageAt ? chatAge(chat.lastMessageAt, now) : undefined;
+ return (
+ : null}
+ meta={age}
+ variant={chat.id === currentChatId ? "selected" : "default"}
+ onSelect={() => onSelect(chat.id)}
+ action={
+ setPendingDelete(chat)}
+ danger
+ />
+ }
+ />
+ );
+ })}
+
)}
-
+
+
+ >
);
}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx
index 1d02bf46621..551902bba57 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx
@@ -1,84 +1,354 @@
import type { UIMessage } from "@ai-sdk/react";
-import { memo } from "react";
-import { Spinner } from "~/components/primitives/Spinner";
-import { MessageBubble, renderPart } from "~/components/runs/v3/agent/AgentMessageView";
-import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
+import { ArrowPathIcon, BookOpenIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import type { AgentIntent } from "@internal/dashboard-agent-contracts";
+import { useNavigate } from "@remix-run/react";
+import { memo, useMemo, useRef } from "react";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
+import { Callout } from "~/components/primitives/Callout";
+import { renderPart, toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";
+import { sameOriginPath } from "./navigate-target";
+import { IN_FLIGHT_TOOL_STATES, liveProgress, type TurnActivity } from "./progress-line";
+import { useTranscriptAutoScroll } from "./useTranscriptAutoScroll";
+import {
+ ChatActionsRow,
+ ChatCardSlot,
+ ChatProgress,
+ ChatText,
+ ChatTranscript,
+ ChatTurn,
+} from "./chat-layout";
+import { reuseWinners } from "./investigation-winners";
+import { stripModelImages } from "./model-markdown";
+import { reportBlockFromToolPart } from "./report-block-adapter";
+import { shouldShowLiveTurnError } from "./turn-error";
+import type { ResolvedUri } from "./ReportView";
+import { answerContinuesAfter } from "./view-actions";
import { ViewBlocks } from "./view-catalog";
-// The shared MessageBubble renders `step-start` parts as a dashed "step"
-// separator — useful in the run inspector / playground, just noise in this
-// simple chat. Drop them before rendering (reference preserved when there are
-// none, so memoization still holds for those messages).
+export type { TurnActivity };
+
+export type DashboardAgentMessagesProps = {
+ messages: UIMessage[];
+ activity: TurnActivity | null;
+ error?: Error;
+ onRetry?: () => void;
+ onDismissError?: () => void;
+ onIntent?: (intent: AgentIntent) => void;
+ resolveUri?: (uri: string) => ResolvedUri | null;
+ pagePaths?: Record
;
+};
+
+// Returns the same reference when there are no `step-start` parts, so memoization holds.
function stripStepParts(message: UIMessage): UIMessage {
if (!message.parts?.some((p) => p.type === "step-start")) return message;
return { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
}
-// A completed render_view tool part carries a `{ blocks }` view spec the agent
-// composed (see the dashboard-agent view catalog). We render those blocks as
-// rich cards instead of the generic tool row.
function viewSpecFor(part: UIMessage["parts"][number]): { blocks: unknown[] } | null {
const p = part as { type: string; output?: { blocks?: unknown[] } };
if (p.type !== "tool-render_view") return null;
return Array.isArray(p.output?.blocks) ? { blocks: p.output!.blocks! } : null;
}
-// Renders one message. Assistant messages that include a completed render_view
-// part get the catalog cards (plus the gather tool rows / lead-in text for
-// transparency); everything else uses the shared MessageBubble unchanged, so
-// its streaming memoization is preserved for the common case.
-const DashboardAgentMessageBubble = memo(function DashboardAgentMessageBubble({
+function blocksFor(part: UIMessage["parts"][number]): unknown[] | null {
+ const spec = viewSpecFor(part);
+ if (spec) return spec.blocks;
+ const hostBlocks = hostViewBlocks(part);
+ if (hostBlocks) return hostBlocks;
+ const report = reportBlockFromToolPart(part);
+ return report ? [report] : null;
+}
+
+function hostViewBlocks(part: UIMessage["parts"][number]): unknown[] | null {
+ const p = part as { type: string; data?: { blocks?: unknown[] } };
+ if (p.type !== "data-view") return null;
+ return Array.isArray(p.data?.blocks) ? p.data!.blocks! : null;
+}
+
+type InvestigationRef = { id: string; revision: number };
+
+function investigationRef(block: unknown): InvestigationRef | null {
+ const b = block as { type?: string; id?: string; revision?: number };
+ if (b?.type !== "investigation" || typeof b.id !== "string") return null;
+ return { id: b.id, revision: typeof b.revision === "number" ? b.revision : 0 };
+}
+
+/** Per investigation id, the one `messageId:partIndex` allowed to render: highest revision. */
+export function winningInvestigationOccurrences(messages: UIMessage[]): Map {
+ const best = new Map();
+ for (const message of messages) {
+ (message.parts ?? []).forEach((part, partIndex) => {
+ for (const block of blocksFor(part) ?? []) {
+ const ref = investigationRef(block);
+ if (!ref) continue;
+ const current = best.get(ref.id);
+ if (!current || ref.revision >= current.revision) {
+ best.set(ref.id, {
+ revision: ref.revision,
+ occurrence: `${message.id}:${partIndex}`,
+ });
+ }
+ }
+ });
+ }
+ return new Map([...best.entries()].map(([id, w]) => [id, w.occurrence]));
+}
+
+// The stable identity is the point: a fresh `Map` re-renders the whole transcript per token.
+function useInvestigationWinners(messages: UIMessage[]): Map {
+ const previous = useRef