diff --git a/.server-changes/dashboard-agent-first-turn-error.md b/.server-changes/dashboard-agent-first-turn-error.md new file mode 100644 index 00000000000..c3dea3a8f8d --- /dev/null +++ b/.server-changes/dashboard-agent-first-turn-error.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +When the assistant fails to start its very first reply, the chat now shows an error you can retry instead of sitting empty. diff --git a/.server-changes/dashboard-agent-investigate.md b/.server-changes/dashboard-agent-investigate.md new file mode 100644 index 00000000000..5fa5a450385 --- /dev/null +++ b/.server-changes/dashboard-agent-investigate.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +The dashboard agent can now investigate failures for you: click Investigate on a failed run, an error, or a backed-up queue and it gathers the evidence, tests a few hypotheses, and answers with what happened and how to fix it — every claim linked to the runs, errors, and deploys behind it. Available to organizations with the dashboard agent enabled. diff --git a/.server-changes/dashboard-agent-watch-alerts.md b/.server-changes/dashboard-agent-watch-alerts.md new file mode 100644 index 00000000000..29473e5722c --- /dev/null +++ b/.server-changes/dashboard-agent-watch-alerts.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Watches you set up with the dashboard agent can now alert you by email, Slack, or webhook when they fire. Pick the new "Dashboard agent watches" type on the Alerts page, and turn it off again from any alert email. diff --git a/.server-changes/dashboard-agent-watches.md b/.server-changes/dashboard-agent-watches.md new file mode 100644 index 00000000000..2374ece89af --- /dev/null +++ b/.server-changes/dashboard-agent-watches.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Ask the dashboard agent to tell you when something happens — a run starting or finishing, a backlog clearing, an error coming back, an environment recovering — and it messages you in the chat once it does. Each chat can wait on up to three things at a time, for up to 24 hours. diff --git a/.server-changes/queue-metrics-api.md b/.server-changes/queue-metrics-api.md new file mode 100644 index 00000000000..72ac98360e3 --- /dev/null +++ b/.server-changes/queue-metrics-api.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +You can now read a single queue's wait times, peak depth, throughput, and how often it was throttled over a time window from the API. diff --git a/.server-changes/remove-header-docs-buttons.md b/.server-changes/remove-header-docs-buttons.md new file mode 100644 index 00000000000..74275b0f93c --- /dev/null +++ b/.server-changes/remove-header-docs-buttons.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +The Docs button has been removed from page headers to reduce clutter. Documentation links remain available in context where they're most useful. diff --git a/apps/webapp/.gitignore b/apps/webapp/.gitignore index 595ab180e15..3595adea8cb 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,6 @@ storybook-static /prisma/seed.js /prisma/populate.js -.memory-snapshots \ No newline at end of file +.memory-snapshots +# Heartbeat story mode for seed-agent-examples (degraded | calm) +.agent-examples-heartbeat-mode diff --git a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx index dbd1297c58f..e811dba27b4 100644 --- a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx +++ b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx @@ -1,12 +1,20 @@ import type { OutputColumnMetadata } from "@internal/clickhouse"; import type { ChartBlock } from "@internal/dashboard-agent"; +import { + isTriggerUri, + type AgentIntent, + type 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 { Button } from "~/components/primitives/Buttons"; import { Spinner } from "~/components/primitives/Spinner"; import { useOptionalEnvironment } from "~/hooks/useEnvironment"; import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { useOptionalProject } from "~/hooks/useProject"; +import { cn } from "~/utils/cn"; +import { ChatActionsRow } from "./chat-layout"; // Render an agent "chart" block by running its TRQL query through the dashboard's // own /resources/metric endpoint (session-authed, returns rows + real column @@ -25,6 +33,21 @@ type MetricResponse = }; }; +// The chart block's schema (`chartBlockBodySchema` in +// @internal/dashboard-agent-contracts) carries only `period` for the time window +// — no scope, no explicit from/to, no height. So these are fixed here rather +// than plumbed from the block. If the schema grows those fields, read them off +// `block` instead of using these. +const CHART_SCOPE = "environment"; // the panel is always open in one environment +const CHART_FROM = null; // `period` is the only window the agent can ask for +const CHART_TO = null; +const CHART_HEIGHT_CLASS = "h-64"; // fits the panel at its default width + +// Query errors come from ClickHouse via the metric endpoint and can carry SQL +// and schema detail, so the panel shows a fixed message and the real one goes to +// the console for whoever is debugging. +const CHART_ERROR_MESSAGE = "This chart's query couldn't run."; + type ChartState = | { status: "loading" } | { status: "error"; error: string } @@ -35,7 +58,53 @@ type ChartState = timeRange?: { from: string; to: string }; }; -export function AgentChart({ block }: { block: ChartBlock }) { +/** + * The buttons under a ranking chart: act on the item the chart put on top. + * + * A card never navigates or asks on its own — it hands the block's intent to the + * host, which submits an `ask` as the user's next message and resolves a + * `navigate` before moving. Without an `onIntent` there is nothing to hand it to, + * so the row isn't rendered rather than showing dead buttons. + */ +export function ChartActions({ + actions, + onIntent, +}: { + actions: ChartAction[]; + onIntent?: (intent: AgentIntent) => void; +}) { + // A chart action's navigate target is a plain string at the contract boundary + // (the model may hold no canonical URI) — only targets that really parse + // become buttons, so a hallucinated URI costs a button, never a dead click. + const renderable = actions.filter( + (action) => action.intent.kind !== "navigate" || isTriggerUri(action.intent.target) + ); + 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(); @@ -63,10 +132,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 +144,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 +157,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]); @@ -110,7 +181,7 @@ export function AgentChart({ block }: { block: ChartBlock }) { {block.title} ) : null} -
+
{state.status === "loading" ? (
@@ -129,6 +200,7 @@ export function AgentChart({ block }: { block: ChartBlock }) { /> )}
+
); } diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx index 2796c8516df..7659cbcb22c 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx @@ -1,11 +1,25 @@ -import { useState } from "react"; +import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts"; +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 { DashboardAgentPanel } from "./DashboardAgentPanel"; import { DashboardAgentProvider } from "./dashboardAgentLauncher"; +import { + showWatchWakesSummaryToast, + showWatchWakeToast, + WAKE_TOAST_MAX_INDIVIDUAL, + type WatchWake, +} from "./WatchWakeToast"; + +// How often the closed panel asks whether a watch woke a chat. A wake is worth +// noticing within a minute, and the count is one indexed query. +const UNREAD_POLL_INTERVAL_MS = 60_000; /** * Mounts the dashboard agent in the env layout. Renders the page content @@ -22,18 +36,135 @@ import { DashboardAgentProvider } from "./dashboardAgentLauncher"; export function DashboardAgent({ children, hasAccess = false, + promotedPrompt, }: { children: React.ReactNode; hasAccess?: boolean; + // The product-controlled promoted prompt chip, from the feature flag. + promotedPrompt?: SuggestedPrompt; }) { + 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); + const [unreadWakes, setUnreadWakes] = useState(0); + // Wakes already toasted this session. Session-scoped on purpose: a wake that + // arrived overnight deserves the toast on the first poll after a reload, but a + // wake the user has already been shown (and maybe dismissed) must not come + // back every 60s while the chat stays unread. + const toastedWakes = useRef(new Set()); + // A request from `openWith`, handed to the panel. `seq` makes repeat requests + // with the same text distinct, so the panel can tell them apart. + const [requestedMessage, setRequestedMessage] = useState< + { text: string; seq: number } | undefined + >(undefined); + // A specific chat to open, from a wake toast. `seq` so the same chat can be + // asked for twice (a second wake in a chat the user has already left). + const [openChatRequest, setOpenChatRequest] = useState< + { chatId: string; seq: number } | undefined + >(undefined); + + // Closing drops any pending request, so reopening the panel later doesn't + // replay text the user has moved on from. + const setPanelOpen = useCallback((next: boolean) => { + setOpen(next); + // Closing drops both pending requests: the panel unmounts, so a stale one + // would re-apply on the next open instead of restoring the last chat. + if (!next) { + setRequestedMessage(undefined); + setOpenChatRequest(undefined); + } + }, []); + + // Open the panel on the chat a wake happened in. Without the chat id the panel + // would just restore whatever it had open last, which is rarely the one the + // toast is about. + 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 })); + }, []); + + // The dot's poll, and the toast's. Runs only while the panel is CLOSED — an + // open panel shows the wake in the transcript, so polling then would only race + // the read marker. Both the interval and the on-close refresh come from this + // effect re-running on `open`. + useEffect(() => { + if (!hasAccess || open) return; + + let cancelled = false; + const load = async () => { + try { + const res = await fetch(`${actionPath}?unread=1`); + if (!res.ok) return; + const data = (await res.json()) as { unreadWakes?: number; wakes?: WatchWake[] }; + if (cancelled) return; + setUnreadWakes(data.unreadWakes ?? 0); + + const fresh = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId)); + for (const wake of fresh) toastedWakes.current.add(wake.watchId); + + // A burst gets one summary toast: a stack of persistent toasts is a wall, + // not a notification. + if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) { + showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true)); + } else { + // Oldest first, so the newest wake ends up nearest the user. + for (const wake of [...fresh].reverse()) { + showWatchWakeToast(wake, openChat); + } + } + } catch { + // Offline or a hiccup — leave the dot as it is and try again next tick. + } + }; + + void load(); + const interval = window.setInterval(load, UNREAD_POLL_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [hasAccess, open, actionPath, setPanelOpen, openChat]); + + // A chat the user is now looking at has no unread wakes. Zeroes the dot right + // away (the poll restores the truth on close if another chat still has one) and + // persists the read marker for the chat that's actually visible. + const markChatRead = useCallback( + async (chatId: string) => { + setUnreadWakes(0); + const body = new FormData(); + body.set("intent", "read"); + body.set("chatId", chatId); + try { + await fetch(actionPath, { method: "POST", body }); + } catch { + // Not worth surfacing: the marker is caught up the next time the chat is + // opened. + } + }, + [actionPath] + ); + + const context = useMemo( + () => ({ open, setOpen: setPanelOpen, openWith, unreadWakes }), + [open, setPanelOpen, openWith, unreadWakes] + ); if (!hasAccess) { return
{children}
; } return ( - + {open ? ( - setOpen(false)} /> + setPanelOpen(false)} + requestedMessage={requestedMessage} + openChatRequest={openChatRequest} + promotedPrompt={promotedPrompt} + onChatRead={markChatRead} + /> ) : ( diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx index e6662ca8494..4f1572d4241 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -1,12 +1,41 @@ 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 { DashboardAgentComposer } from "./DashboardAgentComposer"; import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner"; -import { DashboardAgentMessages } from "./DashboardAgentMessages"; +import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages"; import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts"; +import { createTranscriptOrder, orderTranscript } from "./message-order"; +import { appendRunFilters, pendingNavigateIntents } from "./navigate-target"; +import type { AgentPageContext } from "./page-context-types"; +import { WatchChips, type WatchChip } from "./WatchChips"; + +/** + * The message a card's watch button sends on the user's behalf. Written the way + * the user would ask, so the transcript reads as a request the agent then + * confirms (via schedule_watch), not as UI state that changed silently. + */ +function watchRequestText(spec: WatchSpec): string { + const note = "note" in spec && spec.note ? spec.note.trim() : ""; + if (note) return `Watch this for me — tell me when ${note}.`; + switch (spec.kind) { + case "backlog_drain": + return `Watch this for me — tell me when the ${spec.queue} backlog drains.`; + case "run_start": + return `Watch this for me — tell me when run ${spec.runId} starts.`; + case "run_finished": + return `Watch this for me — tell me when run ${spec.runId} finishes.`; + case "error_recurrence": + return `Watch this for me — ping me if error ${spec.fingerprint} comes back.`; + case "health_recovery": + return "Watch this for me — tell me when health is back to normal."; + } +} // 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 @@ -23,6 +52,10 @@ export type DashboardAgentClientData = { 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; }; /** @@ -44,7 +77,12 @@ export function DashboardAgentChat({ currentPage, pendingFirstMessage, streaming, + prefill, + promotedPrompt, + watches, + onCancelWatch, onTurnSettled, + onActivityChange, }: { chatId: string; initialMessages: UIMessage[]; @@ -54,6 +92,8 @@ export function DashboardAgentChat({ 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. @@ -62,9 +102,36 @@ export function DashboardAgentChat({ // 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[]; + 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(undefined); + useEffect(() => { + if (!prefill || prefilledSeq.current === prefill.seq) return; + prefilledSeq.current = prefill.seq; + setInput(prefill.text); + }, [prefill]); const transport = useTriggerChatTransport({ task: "dashboard-agent", @@ -119,11 +186,12 @@ export function DashboardAgentChat({ }); const { - messages, + messages: rawMessages, sendMessage, status, stop: aiStop, error, + clearError, } = useChat({ id: chatId, messages: initialMessages, @@ -133,8 +201,19 @@ export function DashboardAgentChat({ 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); + const isStreaming = status === "streaming"; - const isThinking = status === "submitted"; + // 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; // Cold start: trigger the first turn by sending the pending message once. const sentFirst = useRef(false); @@ -155,6 +234,87 @@ export function DashboardAgentChat({ [isStreaming, 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. + 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] + ); + + // What a card's action does. An `ask` goes back into the conversation as the + // user's own question — and so does a `watch`: the click becomes a visible + // request ("Watch this for me…") and the agent answers it with schedule_watch, + // confirming in its own words and offering an email alert when none is set up. + // A silent POST would be cheaper, but a watch the transcript never mentions + // reads as nothing having happened. + // + // `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": + submit(watchRequestText(intent.spec)); + return; + case "navigate": + void goTo(intent); + return; + default: + console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`); + } + }, + [submit, goTo] + ); + + // 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 | 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(); @@ -170,6 +330,13 @@ export function DashboardAgentChat({ 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 ( <> - {messages.length === 0 ? ( - + {/* What this chat is watching, right under the banner: 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. */} + 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 ? ( + ) : ( - + )} submit(input)} onStop={stop} isStreaming={isStreaming} + focusKey={prefill?.seq} /> ); diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx index 308a87ebab1..56147a8f11b 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx @@ -1,5 +1,5 @@ import { PaperAirplaneIcon, StopIcon } from "@heroicons/react/20/solid"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { Button } from "~/components/primitives/Buttons"; import { cn } from "~/utils/cn"; @@ -9,21 +9,37 @@ export function DashboardAgentComposer({ onSubmit, onStop, isStreaming, + focusKey, }: { value: string; onChange: (value: string) => void; onSubmit: () => void; onStop: () => void; isStreaming: boolean; + // Bump to move focus back to the textarea — e.g. text was just prefilled from + // outside the panel. Focus also happens on mount (panel open, chat switch). + focusKey?: string | number; }) { const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + el.focus(); + // Caret after any prefilled text, so typing continues the sentence. + el.setSelectionRange(el.value.length, el.value.length); + }, [focusKey]); + return (
+ {/* One text line tall at rest (matches the Send button height), grows + with content up to the cap. rows={1} + field-sizing-content do the + work; the old min-h forced a second line's worth of empty space. */}