improve assistant question placement
This commit is contained in:
1 parent
454a35d1a9
commit
3d7c95ddcc
7 files changed
+300
-154
No files matched your search
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user