improve assistant question placement

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 454a35d1a9
commit 3d7c95ddcc
7 files changed
+300 -154

No files matched your search

+3 -1
View File
@@ -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
@@ -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,
@@ -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 (
<Box className={classes.humanTool} aria-label={t("preparing")}>
<Stack gap="xs">
<Skeleton height={18} width="62%" />
<Skeleton height={52} />
<Skeleton height={52} />
</Stack>
</Box>
<AssistantPendingQuestionPortal>
<Box className={classes.humanTool} aria-label={t("preparing")}>
<Stack gap="xs">
<Skeleton height={18} width="62%" />
<Skeleton height={52} />
<Skeleton height={52} />
</Stack>
</Box>
</AssistantPendingQuestionPortal>
);
}
if (!args?.question || options.length < 2) return <AssistantHumanToolError />;
if (!args?.question || options.length < 2) {
return (
<AssistantPendingQuestionPortal>
<AssistantHumanToolError />
</AssistantPendingQuestionPortal>
);
}
const submitResult = (answer: AskUserResult) => {
if (submitting) return;
@@ -126,128 +135,130 @@ export const AssistantAskUserTool = ({
};
return (
<Box className={classes.humanTool}>
<Group align="flex-start" wrap="nowrap" gap="sm">
<ThemeIcon size="lg" radius="xl" variant="light" color="red">
<IconMessageQuestion size={18} />
</ThemeIcon>
<Box flex={1} miw={0}>
<Text fw={700} className={classes.humanToolQuestion}>
{args.question}
</Text>
{args.description && (
<Text size="sm" c="dimmed" mt={3}>
{args.description}
<AssistantPendingQuestionPortal>
<Box className={classes.humanTool}>
<Group align="flex-start" wrap="nowrap" gap="sm">
<ThemeIcon size="lg" radius="xl" variant="light" color="red">
<IconMessageQuestion size={18} />
</ThemeIcon>
<Box flex={1} miw={0}>
<Text fw={700} className={classes.humanToolQuestion}>
{args.question}
</Text>
)}
</Box>
</Group>
{args.description && (
<Text size="sm" c="dimmed" mt={3}>
{args.description}
</Text>
)}
</Box>
</Group>
<Stack gap="xs" mt="md">
{autoConfirming ? (
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
) : (
options.map((option, index) => {
const kind = getAssistantAskUserOptionKind(option);
const icon =
kind === "affirmative" ? (
<IconCheck size={17} />
) : kind === "negative" ? (
<IconX size={17} />
) : (
<IconArrowRight size={17} />
<Stack gap="xs" mt="md">
{autoConfirming ? (
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
) : (
options.map((option, index) => {
const kind = getAssistantAskUserOptionKind(option);
const icon =
kind === "affirmative" ? (
<IconCheck size={17} />
) : kind === "negative" ? (
<IconX size={17} />
) : (
<IconArrowRight size={17} />
);
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
return (
<Button
key={`${option.id}:${index}`}
className={classes.humanToolOption}
variant={kind === "alternative" ? "default" : "light"}
color={color}
size="md"
fullWidth
justify="space-between"
leftSection={icon}
rightSection={
<Badge variant="light" color={color} size="xs">
{t(`optionKind.${kind}`)}
</Badge>
}
disabled={submitting}
onClick={() =>
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
}
>
<Box ta="start">
<Text component="span" size="sm" fw={650}>
{option.label}
</Text>
{option.description && (
<Text component="span" display="block" size="xs" c="dimmed" fw={400}>
{option.description}
</Text>
)}
</Box>
</Button>
);
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
})
)}
return (
{!autoConfirming &&
args.allowOther !== false &&
(showOther ? (
<Group align="flex-end" gap="xs" wrap="nowrap">
<TextInput
ref={otherInputRef}
className={classes.humanToolOtherInput}
value={other}
onChange={(event) => setOther(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
event.preventDefault();
submitOther();
}
}}
label={t("otherLabel")}
placeholder={t("otherPlaceholder")}
/>
<Button size="md" onClick={submitOther} disabled={!other.trim() || submitting} loading={submitting}>
{t("submit")}
</Button>
<Button
size="md"
variant="default"
px="sm"
aria-label={t("cancelOther")}
onClick={() => {
setShowOther(false);
setOther("");
}}
>
<IconX size={17} />
</Button>
</Group>
) : (
<Button
key={`${option.id}:${index}`}
className={classes.humanToolOption}
variant={kind === "alternative" ? "default" : "light"}
color={color}
variant="default"
size="md"
fullWidth
justify="space-between"
leftSection={icon}
leftSection={<IconPencil size={17} />}
rightSection={
<Badge variant="light" color={color} size="xs">
{t(`optionKind.${kind}`)}
<Badge variant="light" color="gray" size="xs">
{t("optionKind.custom")}
</Badge>
}
disabled={submitting}
onClick={() =>
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
}
justify="flex-start"
onClick={() => setShowOther(true)}
>
<Box ta="start">
<Text component="span" size="sm" fw={650}>
{option.label}
</Text>
{option.description && (
<Text component="span" display="block" size="xs" c="dimmed" fw={400}>
{option.description}
</Text>
)}
</Box>
{t("other")}
</Button>
);
})
)}
{!autoConfirming &&
args.allowOther !== false &&
(showOther ? (
<Group align="flex-end" gap="xs" wrap="nowrap">
<TextInput
ref={otherInputRef}
className={classes.humanToolOtherInput}
value={other}
onChange={(event) => setOther(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
event.preventDefault();
submitOther();
}
}}
label={t("otherLabel")}
placeholder={t("otherPlaceholder")}
/>
<Button size="md" onClick={submitOther} disabled={!other.trim() || submitting} loading={submitting}>
{t("submit")}
</Button>
<Button
size="md"
variant="default"
px="sm"
aria-label={t("cancelOther")}
onClick={() => {
setShowOther(false);
setOther("");
}}
>
<IconX size={17} />
</Button>
</Group>
) : (
<Button
className={classes.humanToolOption}
variant="default"
size="md"
fullWidth
leftSection={<IconPencil size={17} />}
rightSection={
<Badge variant="light" color="gray" size="xs">
{t("optionKind.custom")}
</Badge>
}
justify="flex-start"
onClick={() => setShowOther(true)}
>
{t("other")}
</Button>
))}
</Stack>
</Box>
))}
</Stack>
</Box>
</AssistantPendingQuestionPortal>
);
};
@@ -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%;
@@ -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 (
<Box component="output" className={classes.pendingActionBanner}>
@@ -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 (
<Box component="section" className={classes.pendingQuestionDock} aria-label={t("pendingAction.answerTitle")}>
<div ref={setTarget} className={classes.pendingQuestionTarget} />
</Box>
);
};
const AssistantActivityBar = ({
isRunning,
unreadCount,
@@ -2003,6 +2021,7 @@ export const AssistantConversationSurface = ({
onMinimize,
}: AssistantConversationSurfaceProps) => {
const t = useScopedI18n("common.assistant");
const [questionPortalTarget, setQuestionPortalTarget] = useState<HTMLDivElement | null>(null);
return (
<>
@@ -2063,41 +2082,44 @@ export const AssistantConversationSurface = ({
)}
</Group>
</Group>
<ThreadPrimitive.Root className={classes.thread}>
<ThreadPrimitive.Viewport className={classes.viewport}>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box>
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
<SelectionToolbarPrimitive.Quote asChild>
<Button variant="filled" color="dark" size="compact-sm" leftSection={<IconQuote size={14} />}>
{t("quoteSelection")}
</Button>
</SelectionToolbarPrimitive.Quote>
</SelectionToolbarPrimitive.Root>
<ThreadPrimitive.ScrollToBottom asChild>
<ActionIcon
className={classes.scrollToBottom}
variant="default"
radius="xl"
aria-label={t("scrollToLatest")}
>
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<PendingActionBanner pendingAction={pendingAction} />
<Composer
modelId={modelId}
models={models}
modelOptionsLoading={modelOptionsLoading}
reasoning={reasoning}
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
pendingAction={pendingAction}
/>
</ThreadPrimitive.Root>
<AssistantQuestionPortalProvider target={questionPortalTarget}>
<ThreadPrimitive.Root className={classes.thread}>
<ThreadPrimitive.Viewport className={classes.viewport}>
<Box className={classes.messages}>
<EmptyThread />
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
</Box>
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
<SelectionToolbarPrimitive.Quote asChild>
<Button variant="filled" color="dark" size="compact-sm" leftSection={<IconQuote size={14} />}>
{t("quoteSelection")}
</Button>
</SelectionToolbarPrimitive.Quote>
</SelectionToolbarPrimitive.Root>
<ThreadPrimitive.ScrollToBottom asChild>
<ActionIcon
className={classes.scrollToBottom}
variant="default"
radius="xl"
aria-label={t("scrollToLatest")}
>
<IconArrowUp size={16} style={{ transform: "rotate(180deg)" }} />
</ActionIcon>
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<PendingQuestionDock pendingAction={pendingAction} setTarget={setQuestionPortalTarget} />
<PendingActionBanner pendingAction={pendingAction} />
<Composer
modelId={modelId}
models={models}
modelOptionsLoading={modelOptionsLoading}
reasoning={reasoning}
onModelChange={onModelChange}
onReasoningChange={onReasoningChange}
pendingAction={pendingAction}
/>
</ThreadPrimitive.Root>
</AssistantQuestionPortalProvider>
</>
);
};
@@ -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<HTMLDivElement | null>(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());
});
});
@@ -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<HTMLElement | null>(null);
export const AssistantQuestionPortalProvider = ({
children,
target,
}: PropsWithChildren<{ target: HTMLElement | null }>) => (
<AssistantQuestionPortalContext.Provider value={target}>{children}</AssistantQuestionPortalContext.Provider>
);
export const AssistantPendingQuestionPortal = ({ children }: { children: ReactNode }) => {
const target = useContext(AssistantQuestionPortalContext);
return target ? createPortal(children, target) : children;
};