diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index a572b63a2..8f019efc6 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -145,7 +145,9 @@ floating assistant are visible. When the assistant genuinely needs missing information or a choice, it can pause with a structured question. Each proposed answer is visibly categorized as **Confirm**, **Decline**, or **Option**, so the effect of automatic approval is unambiguous. Users can select one of the proposed answers or choose **Other** and -enter their own response. +enter their own response. While a question is unanswered, its controls stay in a dedicated area immediately +above the composer instead of requiring the user to find the tool call in the transcript. The answered choice +then remains in the conversation history. Submitting the answer resumes the same agent run automatically. The minimized activity bar shows the question or approval that needs attention and opens directly back to the request. While an answer, app form, or mutation approval is pending, the composer preserves its draft but does not replace the pending diff --git a/apps/docs/docs/widgets/assistant/index.mdx b/apps/docs/docs/widgets/assistant/index.mdx index be481f1dc..13c94ae37 100644 --- a/apps/docs/docs/widgets/assistant/index.mdx +++ b/apps/docs/docs/widgets/assistant/index.mdx @@ -45,6 +45,7 @@ active conversation silently: select **Open conversation** inside the widget bef - Mention permitted apps, integrations, boards, widgets, and tools with `@`. - Use `/` shortcuts for common dashboard tasks. - Review tool calls, approve mutations, and answer structured assistant questions inline. +- Answer pending structured questions from the dock above the composer without scrolling through the conversation. - Enable conversation-scoped automatic approvals from the shield control to approve protected mutations, use presented form details, and choose answers categorized as affirmative without individual clicks. - Edit user messages and copy, regenerate, branch, quote, export, or rate responses. - Inspect Markdown, sources, generated files, tool results, reasoning, token use, context-window use, timing, diff --git a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx index d0bc7c534..1945df5df 100644 --- a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx +++ b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx @@ -21,6 +21,7 @@ import { getAssistantAffirmativeResult, getAssistantAskUserOptionKind } from "./ import { useAssistantAutomaticAction } from "./assistant-auto-approval"; import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress"; import classes from "./assistant-panel.module.css"; +import { AssistantPendingQuestionPortal } from "./assistant-question-portal"; import { hasCompleteAssistantToolArguments, hasFailedAssistantToolArguments } from "./assistant-human-tool-status"; import { normalizeAssistantAppIconUrl } from "./assistant-tool-contracts"; import type { AskUserArgs, AskUserResult, ConfigureAppArgs } from "./assistant-tool-contracts"; @@ -101,17 +102,25 @@ export const AssistantAskUserTool = ({ if (!hasCompleteAssistantToolArguments(status)) { return ( - - - - - - - + + + + + + + + + ); } - if (!args?.question || options.length < 2) return ; + if (!args?.question || options.length < 2) { + return ( + + + + ); + } const submitResult = (answer: AskUserResult) => { if (submitting) return; @@ -126,128 +135,130 @@ export const AssistantAskUserTool = ({ }; return ( - - - - - - - - {args.question} - - {args.description && ( - - {args.description} + + + + + + + + + {args.question} - )} - - + {args.description && ( + + {args.description} + + )} + + - - {autoConfirming ? ( - - ) : ( - options.map((option, index) => { - const kind = getAssistantAskUserOptionKind(option); - const icon = - kind === "affirmative" ? ( - - ) : kind === "negative" ? ( - - ) : ( - + + {autoConfirming ? ( + + ) : ( + options.map((option, index) => { + const kind = getAssistantAskUserOptionKind(option); + const icon = + kind === "affirmative" ? ( + + ) : kind === "negative" ? ( + + ) : ( + + ); + const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray"; + + return ( + ); - const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray"; + }) + )} - return ( + {!autoConfirming && + args.allowOther !== false && + (showOther ? ( + + setOther(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + event.preventDefault(); + submitOther(); + } + }} + label={t("otherLabel")} + placeholder={t("otherPlaceholder")} + /> + + + + ) : ( - ); - }) - )} - - {!autoConfirming && - args.allowOther !== false && - (showOther ? ( - - setOther(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && !event.nativeEvent.isComposing) { - event.preventDefault(); - submitOther(); - } - }} - label={t("otherLabel")} - placeholder={t("otherPlaceholder")} - /> - - - - ) : ( - - ))} - - + ))} + + + ); }; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 896435dd8..7a59224f0 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -572,6 +572,31 @@ background: var(--mantine-color-yellow-light); } +.pendingQuestionDock { + flex: 0 1 auto; + max-height: min(22rem, 50cqh); + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.65rem 0.75rem 0.35rem; + border-top: 1px solid var(--mantine-color-default-border); + background: var(--mantine-color-body); +} + +.pendingQuestionTarget { + width: 100%; + max-width: 44rem; + margin-inline: auto; +} + +.pendingQuestionTarget .humanTool { + margin-block: 0; +} + +.pendingQuestionDock + .composerWrap { + padding-top: 0.35rem; + border-top: 0; +} + .humanTool, .appTool { width: 100%; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 6760c1e28..1e397d644 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -104,6 +104,7 @@ import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assista import { normalizeAssistantMarkdown } from "./assistant-markdown"; import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata"; import type { AssistantPendingAction } from "./assistant-pending-action"; +import { AssistantQuestionPortalProvider } from "./assistant-question-portal"; import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll"; import { getToolResultPresentation } from "./assistant-tool-result"; @@ -1851,7 +1852,7 @@ const usePendingActionCopy = (action: AssistantPendingAction | undefined) => { const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendingAction | undefined }) => { const t = useScopedI18n("common.assistant"); const copy = usePendingActionCopy(pendingAction); - if (!copy) return null; + if (!copy || pendingAction?.kind === "question") return null; return ( @@ -1877,6 +1878,23 @@ const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendin ); }; +const PendingQuestionDock = ({ + pendingAction, + setTarget, +}: { + pendingAction: AssistantPendingAction | undefined; + setTarget: (target: HTMLDivElement | null) => void; +}) => { + const t = useScopedI18n("common.assistant"); + if (pendingAction?.kind !== "question") return null; + + return ( + +
+ + ); +}; + const AssistantActivityBar = ({ isRunning, unreadCount, @@ -2003,6 +2021,7 @@ export const AssistantConversationSurface = ({ onMinimize, }: AssistantConversationSurfaceProps) => { const t = useScopedI18n("common.assistant"); + const [questionPortalTarget, setQuestionPortalTarget] = useState(null); return ( <> @@ -2063,41 +2082,44 @@ export const AssistantConversationSurface = ({ )} - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/apps/nextjs/src/components/assistant/assistant-question-portal.spec.ts b/apps/nextjs/src/components/assistant/assistant-question-portal.spec.ts new file mode 100644 index 000000000..5897cb901 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-question-portal.spec.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom + +import { act, createElement, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, test } from "vitest"; + +import { AssistantPendingQuestionPortal, AssistantQuestionPortalProvider } from "./assistant-question-portal"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const containers: HTMLDivElement[] = []; + +afterEach(() => { + for (const container of containers.splice(0)) container.remove(); +}); + +const QuestionSurface = () => { + const [target, setTarget] = useState(null); + return createElement( + AssistantQuestionPortalProvider, + { target }, + createElement( + "div", + { "data-testid": "transcript" }, + createElement(AssistantPendingQuestionPortal, null, "Question"), + ), + createElement("div", { "data-testid": "dock", ref: setTarget }), + ); +}; + +describe("AssistantPendingQuestionPortal", () => { + test("keeps the question visible when a surface has no composer dock", async () => { + const container = document.createElement("div"); + containers.push(container); + document.body.append(container); + const root = createRoot(container); + + await act(async () => + root.render( + createElement( + AssistantQuestionPortalProvider, + { target: null }, + createElement(AssistantPendingQuestionPortal, null, "Question"), + ), + ), + ); + + expect(container.textContent).toBe("Question"); + + await act(async () => root.unmount()); + }); + + test("moves one pending question from the transcript into the composer dock", async () => { + const container = document.createElement("div"); + containers.push(container); + document.body.append(container); + const root = createRoot(container); + + await act(async () => root.render(createElement(QuestionSurface))); + + expect(container.querySelector('[data-testid="transcript"]')?.textContent).toBe(""); + expect(container.querySelector('[data-testid="dock"]')?.textContent).toBe("Question"); + + await act(async () => root.unmount()); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-question-portal.tsx b/apps/nextjs/src/components/assistant/assistant-question-portal.tsx new file mode 100644 index 000000000..f5ea98547 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-question-portal.tsx @@ -0,0 +1,19 @@ +"use client"; + +import type { PropsWithChildren, ReactNode } from "react"; +import { createContext, useContext } from "react"; +import { createPortal } from "react-dom"; + +const AssistantQuestionPortalContext = createContext(null); + +export const AssistantQuestionPortalProvider = ({ + children, + target, +}: PropsWithChildren<{ target: HTMLElement | null }>) => ( + {children} +); + +export const AssistantPendingQuestionPortal = ({ children }: { children: ReactNode }) => { + const target = useContext(AssistantQuestionPortalContext); + return target ? createPortal(children, target) : children; +};