diff --git a/.changeset/report-json-and-period-units.md b/.changeset/report-json-and-period-units.md new file mode 100644 index 00000000000..5567d2a5b9b --- /dev/null +++ b/.changeset/report-json-and-period-units.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Reports can be fetched as structured data with the `json` format. The shortest report period is now one minute (`30m`, `1h`, `7d`). diff --git a/.gitignore b/.gitignore index f540927e32b..b11dded2b02 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,5 @@ ailogger-output.log # observability-map CLI output artifact, not committed observability-map.json + +.claude/worktrees/ diff --git a/.server-changes/dashboard-agent.md b/.server-changes/dashboard-agent.md new file mode 100644 index 00000000000..e40ae2076b9 --- /dev/null +++ b/.server-changes/dashboard-agent.md @@ -0,0 +1,16 @@ +--- +area: webapp +type: feature +--- + +Meet the dashboard agent: a chat in every environment that answers questions about your runs, queues, errors and health with real data and links. It takes over from Ask AI everywhere that used to appear; on the Free plan you get 20 messages. + +**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. + +Separately: a queue's wait times, peak depth, throughput and throttling can now be read from the API, and the Docs button has been removed from page headers. diff --git a/apps/webapp/.gitignore b/apps/webapp/.gitignore index 595ab180e15..f825411d640 100644 --- a/apps/webapp/.gitignore +++ b/apps/webapp/.gitignore @@ -7,6 +7,9 @@ node_modules /cypress/screenshots /cypress/videos +# Output of `pnpm run agent-ui:screenshots` +/screenshots + /app/styles/tailwind.css # Ensure the .env symlink is not removed by accident @@ -20,4 +23,4 @@ storybook-static /prisma/seed.js /prisma/populate.js -.memory-snapshots \ No newline at end of file +.memory-snapshots diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx deleted file mode 100644 index d61ea0055fa..00000000000 --- a/apps/webapp/app/components/AskAI.tsx +++ /dev/null @@ -1,614 +0,0 @@ -import { - ArrowPathIcon, - ArrowUpIcon, - HandThumbDownIcon, - HandThumbUpIcon, - StopIcon, -} from "@heroicons/react/20/solid"; -import { cn } from "~/utils/cn"; -import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk"; -import { useSearchParams } from "@remix-run/react"; -import DOMPurify from "dompurify"; -import { motion } from "framer-motion"; -import { marked } from "marked"; -import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; -import { useTypedRouteLoaderData } from "remix-typedjson"; -import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; -import { SparkleListIcon } from "~/assets/icons/SparkleListIcon"; -import { useFeatures } from "~/hooks/useFeatures"; -import { useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { type loader } from "~/root"; -import { Button } from "./primitives/Buttons"; -import { Callout } from "./primitives/Callout"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./primitives/Dialog"; -import { Header2 } from "./primitives/Headers"; -import { Paragraph } from "./primitives/Paragraph"; -import { ShortcutKey } from "./primitives/ShortcutKey"; -import { Spinner } from "./primitives/Spinner"; -import { - SimpleTooltip, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "./primitives/Tooltip"; -import { ClientOnly } from "remix-utils/client-only"; - -function useKapaWebsiteId() { - const routeMatch = useTypedRouteLoaderData("root"); - return routeMatch?.kapa.websiteId; -} - -/** Open/close state for the Ask AI dialog, including the `?aiHelp=` deep-link handling. */ -function useAskAIState() { - const [isOpen, setIsOpen] = useState(false); - const [initialQuery, setInitialQuery] = useState(); - const [searchParams, setSearchParams] = useSearchParams(); - - const openAskAI = useCallback((question?: string) => { - if (question) { - setInitialQuery(question); - } else { - setInitialQuery(undefined); - } - setIsOpen(true); - }, []); - - const closeAskAI = useCallback(() => { - setIsOpen(false); - setInitialQuery(undefined); - }, []); - - // Handle URL param functionality - useEffect(() => { - const aiHelp = searchParams.get("aiHelp"); - if (aiHelp) { - // Delay to avoid hCaptcha bot detection - window.setTimeout(() => openAskAI(aiHelp), 1000); - - // Clone instead of mutating in place - const next = new URLSearchParams(searchParams); - next.delete("aiHelp"); - setSearchParams(next); - } - }, [searchParams, openAskAI]); - - return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI }; -} - -/** - * Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog) for a menu that renders its own trigger. Wrap - * it around the popover, not inside, so the dialog and shortcut survive the popover closing. - * `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no - * Kapa website id, or SSR). - */ -export function AskAIRoot({ - children, -}: { - children: (openAskAI: (() => void) | undefined) => ReactNode; -}) { - const { isManagedCloud } = useFeatures(); - const websiteId = useKapaWebsiteId(); - - if (!isManagedCloud || !websiteId) { - return <>{children(undefined)}; - } - - return ( - {children(undefined)}}> - {() => {children}} - - ); -} - -function AskAIRootProvider({ - websiteId, - children, -}: { - websiteId: string; - children: (openAskAI: () => void) => ReactNode; -}) { - const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); - - useShortcutKeys({ - shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true }, - action: () => openAskAI(), - }); - - return ( - openAskAI(), - onAnswerGenerationCompleted: () => openAskAI(), - }, - }} - botProtectionMechanism="hcaptcha" - > - {children(() => openAskAI())} - - - ); -} - -export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) { - const { isManagedCloud } = useFeatures(); - const websiteId = useKapaWebsiteId(); - - if (!isManagedCloud || !websiteId) { - return null; - } - - return ( - - - - } - > - {() => } - - ); -} - -type AskAIProviderProps = { - websiteId: string; - isCollapsed?: boolean; -}; - -function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) { - const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); - - return ( - openAskAI(), - onAnswerGenerationCompleted: () => openAskAI(), - }, - }} - botProtectionMechanism="hcaptcha" - > - - - - - - - - - - Ask AI - - - - - - - - - - - ); -} - -type AskAIDialogProps = { - initialQuery?: string; - isOpen: boolean; - onOpenChange: (open: boolean) => void; - closeAskAI: () => void; -}; - -function AskAIDialog({ initialQuery, isOpen, onOpenChange, closeAskAI }: AskAIDialogProps) { - const handleOpenChange = (open: boolean) => { - if (!open) { - closeAskAI(); - } else { - onOpenChange(open); - } - }; - - return ( - - - -
- - Ask AI -
-
- -
-
- ); -} - -function ChatMessages({ - conversation, - isPreparingAnswer, - isGeneratingAnswer, - onReset, - onExampleClick, - error, - addFeedback, -}: { - conversation: QA[]; - isPreparingAnswer: boolean; - isGeneratingAnswer: boolean; - onReset: () => void; - onExampleClick: (question: string) => void; - error: string | null; - addFeedback: ( - questionAnswerId: string, - reaction: "upvote" | "downvote", - comment?: FeedbackComment - ) => void; -}) { - const [feedbackGivenForQAs, setFeedbackGivenForQAs] = useState>(new Set()); - - // Reset feedback state when conversation is reset - useEffect(() => { - if (conversation.length === 0) { - setFeedbackGivenForQAs(new Set()); - } - }, [conversation.length]); - - // Check if feedback has been given for the latest QA - const latestQA = conversation[conversation.length - 1]; - const hasFeedbackForLatestQA = latestQA?.id ? feedbackGivenForQAs.has(latestQA.id) : false; - - const exampleQuestions = [ - "How do I increase my concurrency limit?", - "How do I debug errors in my task?", - "How do I deploy my task?", - ]; - - return ( -
- {conversation.length === 0 ? ( - - - I'm trained on docs, examples, and other content. Ask me anything about Trigger.dev. - - {exampleQuestions.map((question, index) => ( - onExampleClick(question)} - variants={{ - hidden: { - opacity: 0, - x: 20, - }, - visible: { - opacity: 1, - x: 0, - transition: { - opacity: { - duration: 0.5, - ease: "linear", - }, - x: { - type: "spring", - stiffness: 300, - damping: 25, - }, - }, - }, - }} - > - - - {question} - - - ))} - - ) : ( - conversation.map((qa) => ( -
- {qa.question} -
-
- )) - )} - {conversation.length > 0 && - !isPreparingAnswer && - !isGeneratingAnswer && - !error && - !latestQA?.id && ( -
- - Answer generation was stopped - - -
- )} - {conversation.length > 0 && - !isPreparingAnswer && - !isGeneratingAnswer && - !error && - latestQA?.id && ( -
- {hasFeedbackForLatestQA ? ( - - - Thanks for your feedback! - - - ) : ( -
- - Was this helpful? - -
- - -
-
- )} - -
- )} - {isPreparingAnswer && ( -
- - Preparing answer… -
- )} - {error && ( -
- - Error generating answer: - - {error} If the problem persists after retrying, please contact support. - - -
- -
-
- )} -
- ); -} - -function ChatInterface({ initialQuery }: { initialQuery?: string }) { - const [message, setMessage] = useState(""); - const [isExpanded, setIsExpanded] = useState(false); - const hasSubmittedInitialQuery = useRef(false); - const { - conversation, - submitQuery, - isGeneratingAnswer, - isPreparingAnswer, - resetConversation, - stopGeneration, - error, - addFeedback, - } = useChat(); - - useEffect(() => { - if (initialQuery && !hasSubmittedInitialQuery.current) { - hasSubmittedInitialQuery.current = true; - setIsExpanded(true); - submitQuery(initialQuery); - } - }, [initialQuery, submitQuery]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (message.trim()) { - setIsExpanded(true); - submitQuery(message); - setMessage(""); - } - }; - - const handleExampleClick = (question: string) => { - setIsExpanded(true); - submitQuery(question); - }; - - const handleReset = () => { - resetConversation(); - setIsExpanded(false); - }; - - return ( - - -
-
- setMessage(e.target.value)} - placeholder="Ask a question..." - disabled={isGeneratingAnswer} - autoFocus - className="flex-1 rounded-md border border-grid-bright bg-background-dimmed px-3 py-2 text-text-bright placeholder:text-text-dimmed focus-visible:focus-custom" - /> - {isGeneratingAnswer ? ( - stopGeneration()} - className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center" - > - - - - } - content="Stop generating" - /> - ) : isPreparingAnswer ? ( - - - - ) : ( -
-
-
- ); -} - -function GradientSpinnerBackground({ - children, - className, - hoverEffect = false, -}: { - children?: React.ReactNode; - className?: string; - hoverEffect?: boolean; -}) { - return ( -
-
- {children} -
-
- ); -} diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 53b552155be..41901732207 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -34,7 +34,7 @@ import { v3NewProjectAlertPath, v3NewSchedulePath, } from "~/utils/pathBuilder"; -import { AskAI } from "./AskAI"; +import { AskAgentButton } from "./dashboard-agent/AskAgentButton"; import { CodeBlock } from "./code/CodeBlock"; import { InlineCode } from "./code/InlineCode"; import { environmentFullTitle, EnvironmentIcon } from "./environments/EnvironmentLabel"; @@ -62,6 +62,38 @@ import { import { StepContentContainer } from "./StepContentContainer"; import { V4Badge } from "./V4Badge"; +const ASK_AGENT_DEPLOY_PROMPT = + "I'm trying to deploy my tasks to this environment. Walk me through it and tell me if anything about this project or environment is going to get in the way."; + +function DeployDocsLinks() { + return ( + <> + + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + + ); +} + export function HasNoTasksDev() { return ( @@ -270,29 +302,7 @@ export function DeploymentsNoneDev() { Deploy your tasks
- - } - content="Deploy docs" - /> - - } - content="Troubleshooting docs" - /> - + } />
@@ -658,29 +668,7 @@ function DeploymentOnboardingSteps() {
- - } - content="Deploy docs" - /> - - } - content="Troubleshooting docs" - /> - + } />
diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index c4ce2db6d0f..202d4d8bcf6 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -1,5 +1,8 @@ import { KeyboardIcon } from "~/assets/icons/KeyboardIcon"; import { useState } from "react"; +import { ASK_AGENT_LABEL } from "~/components/dashboard-agent/agent-identity"; +import { NEW_CHAT_SHORTCUT } from "~/components/dashboard-agent/DashboardAgentHeader"; +import { TOGGLE_PANEL_SHORTCUT } from "~/components/dashboard-agent/dashboardAgentLauncher"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { Header3 } from "./primitives/Headers"; import { SideMenuItemButton } from "./navigation/SideMenuItem"; @@ -62,9 +65,9 @@ function ShortcutContent() { - + - + @@ -94,6 +97,19 @@ function ShortcutContent() { +
+ Chat + + + + + + + +
Runs page diff --git a/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx b/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx new file mode 100644 index 00000000000..4ef3a276b74 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx @@ -0,0 +1,31 @@ +import type { + ActionsBlock as ActionsBlockPayload, + AgentIntent, +} from "@internal/dashboard-agent-contracts"; +import { Button } from "~/components/primitives/Buttons"; +import { ChatActionsRow } from "./chat-layout"; +import { renderableActions } from "./view-actions"; + +export function ActionsBlock({ + block, + onIntent, +}: { + block: ActionsBlockPayload; + onIntent?: (intent: AgentIntent) => void; +}) { + const renderable = renderableActions(block.actions); + if (!onIntent || renderable.length === 0) return null; + return ( + + {renderable.map((action, i) => ( + + ))} + + ); +} diff --git a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx index dbd1297c58f..dda01b7f192 100644 --- a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx +++ b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx @@ -1,18 +1,18 @@ import type { OutputColumnMetadata } from "@internal/clickhouse"; import type { ChartBlock } from "@internal/dashboard-agent"; +import type { AgentIntent, ChartAction } from "@internal/dashboard-agent-contracts"; import { useEffect, useState } from "react"; import { QueryResultsChart } from "~/components/code/QueryResultsChart"; import type { ChartConfiguration } from "~/components/metrics/QueryWidget"; -import { Spinner } from "~/components/primitives/Spinner"; +import { Button } from "~/components/primitives/Buttons"; +import { AgentSpinner } from "~/components/primitives/Spinner"; import { useOptionalEnvironment } from "~/hooks/useEnvironment"; import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { useOptionalProject } from "~/hooks/useProject"; - -// Render an agent "chart" block by running its TRQL query through the dashboard's -// own /resources/metric endpoint (session-authed, returns rows + real column -// metadata) and feeding the result into QueryResultsChart. So the chart is live -// and matches the Query page exactly: the agent only emits the query + chart -// config, never the rows. Runs against the project/env the panel is open in. +import { cn } from "~/utils/cn"; +import { AgentCard, AgentCardHeader } from "./agent-card"; +import { ChatActionsRow } from "./chat-layout"; +import { renderableActions } from "./view-actions"; type MetricResponse = | { success: false; error: string } @@ -25,6 +25,18 @@ type MetricResponse = }; }; +// `chartBlockBodySchema` carries only `period`, so scope and from/to are fixed here. +const CHART_SCOPE = "environment"; +const CHART_FROM = null; +const CHART_TO = null; +// `min-h` as well: the chart draws nothing at zero height if a flex parent collapses it. +const CHART_HEIGHT_CLASS = "h-64 min-h-64"; +const CHART_PADDING_CLASS = "px-2 pb-2 pt-4"; +export const AGENT_CHART_PLOT_CLASS = `w-full ${CHART_PADDING_CLASS} ${CHART_HEIGHT_CLASS}`; + +// Query errors can carry SQL and schema detail, so the real one only goes to the console. +const CHART_ERROR_MESSAGE = "This chart's query couldn't run."; + type ChartState = | { status: "loading" } | { status: "error"; error: string } @@ -35,7 +47,39 @@ type ChartState = timeRange?: { from: string; to: string }; }; -export function AgentChart({ block }: { block: ChartBlock }) { +export function ChartActions({ + actions, + onIntent, +}: { + actions: ChartAction[]; + onIntent?: (intent: AgentIntent) => void; +}) { + const renderable = renderableActions(actions); + if (!onIntent || renderable.length === 0) return null; + return ( +
+ + {renderable.map((action, i) => ( + + ))} + +
+ ); +} + +export function AgentChart({ + block, + onIntent, +}: { + block: ChartBlock; + onIntent?: (intent: AgentIntent) => void; +}) { const organization = useOptionalOrganization(); const project = useOptionalProject(); const environment = useOptionalEnvironment(); @@ -46,8 +90,7 @@ export function AgentChart({ block }: { block: ChartBlock }) { const environmentId = environment?.id; useEffect(() => { - // The block can render before its `query` has finished streaming in; wait - // for it rather than POST an empty query (which 400s). + // The block can render before `query` has streamed in; an empty query 400s. if (!block.query) return; if (!organizationId || !projectId || !environmentId) { setState({ status: "error", error: "No environment context to run the query." }); @@ -63,10 +106,10 @@ export function AgentChart({ block }: { block: ChartBlock }) { organizationId, projectId, environmentId, - scope: "environment", + scope: CHART_SCOPE, period: block.period ?? null, - from: null, - to: null, + from: CHART_FROM, + to: CHART_TO, userAuthoredQuery: true, }), signal: controller.signal, @@ -75,7 +118,8 @@ export function AgentChart({ block }: { block: ChartBlock }) { .then((data) => { if (controller.signal.aborted) return; if (!data.success) { - setState({ status: "error", error: data.error }); + console.error("Dashboard agent chart query failed:", data.error); + setState({ status: "error", error: CHART_ERROR_MESSAGE }); } else { setState({ status: "ready", @@ -87,7 +131,8 @@ export function AgentChart({ block }: { block: ChartBlock }) { }) .catch((err) => { if (controller.signal.aborted) return; - setState({ status: "error", error: err?.message ?? "The query failed to run." }); + console.error("Dashboard agent chart request failed:", err); + setState({ status: "error", error: CHART_ERROR_MESSAGE }); }); return () => controller.abort(); }, [block.query, block.period, organizationId, projectId, environmentId]); @@ -104,16 +149,16 @@ export function AgentChart({ block }: { block: ChartBlock }) { }; return ( -
+ {block.title ? ( -
+ {block.title} -
+ ) : null} -
+
{state.status === "loading" ? (
- + Running query…
) : 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..9b58f5500a5 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx @@ -1,53 +1,294 @@ -import { useState } from "react"; +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 { 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, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher"; +import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest"; +import { + agentHiddenContentClassName, + agentTakeoverClassName, + readAgentFullscreen, + writeAgentFullscreen, +} from "./panel-layout"; +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, + /** 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; + 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); + const toastedWakes = useRef(new Set()); + // 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(null); + // 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); + // `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, + organizationId: organization.id, + }) + ) + setWatching(true); + }; + sync(); + return subscribeWatchActivity(sync); + }, [organization.id, initialUnreadWakes, hasActiveWatches]); + + 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; 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)); + + const fresh = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId)); + for (const wake of fresh) rememberToasted(wake.watchId); + + 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); + 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, + action: () => { + if (!open) { + setPanelOpen(true); + } else { + setNewChatSeq((seq) => seq + 1); + } + }, + disabled: !hasAccess, + enabledOnInputElements: true, + }); + + useDashboardAgentOpenRequests({ enabled: hasAccess, openWith, setOpen: setPanelOpen }); + + const context = useMemo( + () => ({ open, setOpen: setPanelOpen, openWith, openWithWatch, unreadWakes }), + [open, setPanelOpen, openWith, openWithWatch, unreadWakes] + ); if (!hasAccess) { return
{children}
; } return ( - + {open ? ( - - -
{children}
-
- - - setOpen(false)} /> - -
+ // `relative` is the takeover's containing block. +
+ + +
{children}
+
+ + +
+ setPanelOpen(false)} + requestedMessage={requestedMessage} + openChatRequest={openChatRequest} + watchRequest={watchRequest} + newChatSeq={newChatSeq} + promotedPrompt={promotedPrompt} + onChatRead={markChatRead} + 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..11261222d5c 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -1,37 +1,52 @@ import { useChat } from "@ai-sdk/react"; import type { UIMessage } from "@ai-sdk/react"; import type { dashboardAgent } from "@internal/dashboard-agent"; +import { + isWatchRequestMessageId, + type AgentIntent, + type SuggestedPrompt, + type 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 { 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, pendingWatchIntents } 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"; +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. +// 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 +59,16 @@ export function DashboardAgentChat({ currentPage, pendingFirstMessage, streaming, + prefill, + promotedPrompt, + watches, + pagePaths, + watchCard, + appendedMessages, + onWatchIntent, + onCancelWatch, onTurnSettled, + onActivityChange, }: { chatId: string; initialMessages: UIMessage[]; @@ -54,31 +78,53 @@ 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; + watches: WatchChip[]; + pagePaths?: Record; + watchCard?: React.ReactNode; + appendedMessages?: { messages: UIMessage[]; seq: number }; + /** Nothing is persisted until the user submits the card. */ + onWatchIntent?: (spec: WatchSpec) => void; + onCancelWatch: (watchId: string) => void; 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 +132,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 +163,46 @@ 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; + + // Once per `seq`: the append is already persisted, so a replay would duplicate it. + // Ids are stable, so anything already in the transcript is skipped. + const appendedSeq = useRef(undefined); + useEffect(() => { + if (!appendedMessages || appendedSeq.current === appendedMessages.seq) return; + appendedSeq.current = appendedMessages.seq; + setMessages((current) => { + const missing = appendedMessages.messages.filter( + (message) => !current.some((existing) => existing.id === message.id) + ); + return missing.length === 0 ? current : [...current, ...missing]; + }); + }, [appendedMessages, setMessages]); - // Cold start: trigger the first turn by sending the pending message once. const sentFirst = useRef(false); useEffect(() => { if (pendingFirstMessage && !sentFirst.current) { @@ -148,47 +214,183 @@ 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(() => { + // A watch's consent record is a user message nobody typed, so retry skips it. + const lastUserMessage = [...messages] + .reverse() + .find((m) => m.role === "user" && !isWatchRequestMessageId(m.id)); + 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 "watch": + onWatchIntent?.(intent.spec); + return; + case "navigate": + void goTo(intent); + return; + default: + console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`); + } + }, + [submit, goTo, onWatchIntent] + ); + + // 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 watchProposedRef = useRef | null>(null); + if (watchProposedRef.current === null) { + watchProposedRef.current = new Set(); + pendingWatchIntents(initialMessages, watchProposedRef.current); + } + useEffect(() => { + const pending = pendingWatchIntents(messages, watchProposedRef.current!); + const proposed = pending.at(-1); + if (proposed) onWatchIntent?.(proposed.spec); + }, [messages, onWatchIntent]); + 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 ( <> - watch.status === "active")} + onCancel={onCancelWatch} /> - {messages.length === 0 ? ( - + {messages.length === 0 && !pendingFirstMessage ? ( + ) : ( - + + )} + {watchCard ?
{watchCard}
: null} + {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 ? ( +