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

@@ -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;
};