authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-17 00:55:43+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 18:55:43-03:00
log01e64193d9d2abfae8f7b3d20f66824e38397bd6
treef2b24958ae1c6afcb655e22b4c2563b88624a5ba
parentf1b497a5552abf4616a7477b730b78a1274f3db4
signature Signed by PGP key B5690EEEBB952194

fix(web): simplify agent approval prompts (#12082)


7 files changed, 108 insertions(+), 89 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+8-4
......@@ -933,6 +933,7 @@ import {
933933 PaperclipIcon,
934934 PencilRulerIcon,
935935 PlayIcon,
936 ShieldIcon,
936937 XIcon,
937938} from "lucide-react";
938939import { proposedPlanTitle } from "../../proposedPlan";
......@@ -6042,13 +6043,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
60426043 <ComposerBanner.Root
60436044 data-chat-composer-top-drawer="true"
60446045 variant={activePendingApproval ? "warning" : "info"}
6046 density={activePendingApproval ? "spacious" : "default"}
60456047 >
60466048 {activePendingApproval ? (
60476049 <ComposerBanner.Row
6048 layout="wrap-actions"
6050 layout="approval"
60496051 data-chat-composer-collapsed-controls="true"
60506052 >
6051 <ComposerBanner.Icon />
6053 <ComposerBanner.Icon>
6054 <ShieldIcon />
6055 </ComposerBanner.Icon>
60526056 <ComposerBanner.Content>
60536057 <ComposerPendingApprovalPanel
60546058 approval={activePendingApproval}
......@@ -6712,6 +6716,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
67126716 showMobilePendingAnswerActions && "max-sm:pb-11",
67136717 isComposerResting &&
67146718 "max-h-8 min-h-8 overflow-hidden whitespace-pre! leading-8",
6719 isComposerApprovalState && "min-h-8",
67156720 )}
67166721 placeholderClassName={cn(
67176722 isComposerResting &&
......@@ -6727,8 +6732,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
67276732 onPaste={onComposerPaste}
67286733 placeholder={
67296734 isComposerApprovalState
6730 ? (activePendingApproval?.detail ??
6731 "Resolve this approval request to continue")
6735 ? "Resolve this approval request to continue"
67326736 : activePendingProgress
67336737 ? isChoiceOnlyPendingQuestion
67346738 ? "Choose an option above"
apps/web/src/components/chat/ComposerBanner.tsx+7-2
......@@ -153,7 +153,7 @@ function Root({
153153 width = "fill",
154154 ...props
155155}: ComponentProps<"div"> & {
156 density?: "default" | "comfortable";
156 density?: "default" | "comfortable" | "spacious";
157157 placement?: "attached" | "floating";
158158 variant?: ComposerBannerVariant;
159159 width?: "fill" | "content";
......@@ -163,6 +163,7 @@ function Root({
163163 className={cn(
164164 "min-w-0 px-1 pt-(--composer-banner-padding-block) pb-[calc(var(--chat-composer-attachment-overlap)+var(--composer-banner-padding-block))] text-xs/4 [--composer-banner-icon-column:--spacing(7)] [--composer-banner-padding-block:--spacing(1)] sm:[--composer-banner-icon-column:--spacing(6)]",
165165 density === "comfortable" && "[--composer-banner-padding-block:--spacing(1.25)]",
166 density === "spacious" && "px-3 [--composer-banner-padding-block:--spacing(3)]",
166167 width === "content" ? "w-fit max-w-full flex-none" : "@container",
167168 className,
168169 )}
......@@ -182,7 +183,7 @@ function Row({
182183 layout = "inline",
183184 ...props
184185}: useRender.ComponentProps<"div"> & {
185 layout?: "inline" | "wrap-actions" | "wrap-actions-narrow";
186 layout?: "inline" | "wrap-actions" | "wrap-actions-narrow" | "approval";
186187}) {
187188 const rowProps = {
188189 className: cn(
......@@ -193,6 +194,7 @@ function Row({
193194 "@max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)",
194195 layout === "wrap-actions-narrow" &&
195196 "@max-[320px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)",
197 layout === "approval" && "items-start gap-x-2 gap-y-3",
196198 className,
197199 ),
198200 "data-composer-banner-row": "true",
......@@ -212,6 +214,7 @@ function Icon({ className, ...props }: ComponentProps<"span">) {
212214 data-slot="composer-banner-icon"
213215 className={cn(
214216 "col-start-1 row-start-1 flex w-(--composer-banner-icon-column) min-w-0 flex-none items-center justify-center text-muted-foreground [&>svg]:size-3",
217 "group-data-[composer-banner-layout=approval]/banner-row:pt-0.5 group-data-[composer-banner-layout=approval]/banner-row:text-warning group-data-[composer-banner-layout=approval]/banner-row:[&>svg]:size-4",
215218 className,
216219 )}
217220 {...props}
......@@ -225,6 +228,7 @@ function Content({ className, ...props }: ComponentProps<"span">) {
225228 data-slot="composer-banner-content"
226229 className={cn(
227230 "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0",
231 "@max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4",
228232 "group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:col-[1/3] group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-2 sm:group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-1.5",
229233 "group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-2 sm:group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-1.5",
230234 className,
......@@ -252,6 +256,7 @@ function Actions({ className, ...props }: ComponentProps<"span">) {
252256 data-slot="composer-banner-actions"
253257 className={cn(
254258 "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1",
259 "group-data-[composer-banner-layout=approval]/banner-row:self-center group-data-[composer-banner-layout=approval]/banner-row:gap-1.5 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-start-2 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:row-start-2",
255260 "@max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:col-start-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:col-end-4 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:row-start-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:-ms-2 @max-[400px]:group-data-[composer-banner-layout=wrap-actions]/banner-row:has-[>:nth-child(2)]:justify-start",
256261 "@max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:col-start-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:col-end-4 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:row-start-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:-ms-2 @max-[320px]:group-data-[composer-banner-layout=wrap-actions-narrow]/banner-row:has-[>:nth-child(2)]:justify-start",
257262 className,
apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx+12-35
......@@ -5,7 +5,7 @@ import { describe, expect, it } from "vite-plus/test";
55import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
66
77describe("ComposerPendingApprovalActions", () => {
8 it("states that the persistent approval lasts for this session", () => {
8 it("keeps the main decisions visible and secondary decisions in the menu", () => {
99 const markup = renderToStaticMarkup(
1010 <ComposerPendingApprovalActions
1111 requestId={ApprovalRequestId.make("approval-1")}
......@@ -14,15 +14,13 @@ describe("ComposerPendingApprovalActions", () => {
1414 />,
1515 );
1616
17 expect(markup).toContain(">Cancel<");
18 expect(markup).toContain("Always allow this session");
19 expect(markup).not.toContain(">Always allow<");
20 expect(markup).toContain("h-5");
21 expect(markup).toContain("sm:text-[11px]");
22 expect(markup).not.toContain("sm:h-6");
17 expect(markup).toContain(">Decline<");
18 expect(markup).toContain(">Approve<");
19 expect(markup).not.toContain(">Cancel<");
20 expect(markup).not.toContain("Always allow this session");
2321 });
2422
25 it("shows only the approval choices advertised by an MCP server", () => {
23 it("keeps secondary provider labels out of the compact action row", () => {
2624 const markup = renderToStaticMarkup(
2725 <ComposerPendingApprovalActions
2826 requestId={ApprovalRequestId.make("approval-safari")}
......@@ -36,48 +34,27 @@ describe("ComposerPendingApprovalActions", () => {
3634 />,
3735 );
3836
39 expect(markup).toContain("Always allow Safari");
37 expect(markup).not.toContain("Always allow Safari");
4038 expect(markup).toContain(">Approve<");
4139 expect(markup).not.toContain("Always allow this session");
4240 });
4341
44 it("marks an option that carries a provider warning", () => {
42 it("preserves provider labels for the main decisions", () => {
4543 const markup = renderToStaticMarkup(
4644 <ComposerPendingApprovalActions
4745 requestId={ApprovalRequestId.make("approval-1")}
4846 isResponding={false}
4947 options={[
5048 { decision: "accept", label: "Allow once" },
51 {
52 decision: "acceptForSession",
53 label: "Allow for this thread",
54 warning: "Untrusted files could re-run this action without asking.",
55 },
5649 { decision: "decline", label: "Deny" },
5750 ]}
5851 onRespondToApproval={async () => undefined}
5952 />,
6053 );
6154
62 expect(markup).toContain(
63 'aria-description="Untrusted files could re-run this action without asking."',
64 );
65 expect(markup).toContain("text-warning");
66 expect(markup).toContain("Allow for this thread");
67 });
68
69 it("limits provider-supplied approval labels so narrow rows can wrap", () => {
70 const label = "Allow ".repeat(40).trim();
71 const markup = renderToStaticMarkup(
72 <ComposerPendingApprovalActions
73 requestId={ApprovalRequestId.make("approval-long-label")}
74 isResponding={false}
75 options={[{ decision: "acceptAlways", label }]}
76 onRespondToApproval={async () => undefined}
77 />,
78 );
79
80 expect(markup).toContain('class="max-w-40 truncate"');
81 expect(markup).toContain(label);
55 expect(markup).toContain("Allow once");
56 expect(markup).toContain("Deny");
57 expect(markup).not.toContain(">Approve<");
58 expect(markup).not.toContain(">Decline<");
8259 });
8360});
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+55-16
......@@ -4,9 +4,11 @@ import {
44 type ProviderApprovalOption,
55} from "@t3tools/contracts";
66import { memo } from "react";
7import { TriangleAlertIcon } from "lucide-react";
7import { EllipsisIcon, TriangleAlertIcon } from "lucide-react";
88import { Button } from "../ui/button";
9import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
910import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
11import { composerFloatingLayerProps } from "./composerEventScope";
1012
1113interface ComposerPendingApprovalActionsProps {
1214 requestId: ApprovalRequestId;
......@@ -18,7 +20,6 @@ interface ComposerPendingApprovalActionsProps {
1820 ) => Promise<unknown>;
1921}
2022
21const APPROVAL_ACTION_CLASS_NAME = "font-normal";
2223const DEFAULT_APPROVAL_OPTIONS = [
2324 { decision: "cancel", label: "Cancel" },
2425 { decision: "decline", label: "Decline" },
......@@ -32,23 +33,21 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
3233 options = DEFAULT_APPROVAL_OPTIONS,
3334 onRespondToApproval,
3435}: ComposerPendingApprovalActionsProps) {
36 const primaryOptions = options.filter(
37 (option) => option.decision === "decline" || option.decision === "accept",
38 );
39 const moreOptions = options.filter(
40 (option) => option.decision !== "decline" && option.decision !== "accept",
41 );
42
3543 return (
3644 <>
37 {options.map((option) => {
45 {primaryOptions.map((option) => {
3846 const button = (
3947 <Button
4048 key={option.decision}
41 size="micro"
42 variant="ghost-muted"
43 className={`${APPROVAL_ACTION_CLASS_NAME}${
44 option.decision === "decline"
45 ? " text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground"
46 : option.decision === "accept"
47 ? " text-foreground"
48 : option.warning
49 ? " text-warning"
50 : ""
51 }`}
49 size="xs"
50 variant={option.decision === "accept" ? "default" : "outline"}
5251 disabled={isResponding}
5352 aria-description={option.warning}
5453 onClick={() => void onRespondToApproval(requestId, option.decision)}
......@@ -57,8 +56,6 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
5756 <span className="max-w-40 truncate">{option.label}</span>
5857 </Button>
5958 );
60 // A provider caution, such as a prompt injection warning on "allow
61 // always", rides along as a tooltip so the row stays one line.
6259 return option.warning ? (
6360 <Tooltip key={option.decision}>
6461 <TooltipTrigger render={button} />
......@@ -70,6 +67,48 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
7067 button
7168 );
7269 })}
70 {moreOptions.length > 0 ? (
71 <Menu>
72 <MenuTrigger
73 disabled={isResponding}
74 render={<Button size="icon-xs" variant="outline" aria-label="More approval options" />}
75 >
76 <EllipsisIcon />
77 </MenuTrigger>
78 <MenuPopup
79 {...composerFloatingLayerProps}
80 side="top"
81 align="end"
82 className="w-56 max-w-[calc(100vw-2rem)]"
83 >
84 {moreOptions.map((option) => {
85 const item = (
86 <MenuItem
87 key={option.decision}
88 disabled={isResponding}
89 aria-description={option.warning}
90 onClick={() => void onRespondToApproval(requestId, option.decision)}
91 variant="ghost"
92 className="mb-1 last:mb-0"
93 >
94 {option.warning ? <TriangleAlertIcon className="size-3 text-warning" /> : null}
95 <span className="min-w-0 whitespace-normal wrap-break-word">{option.label}</span>
96 </MenuItem>
97 );
98 return option.warning ? (
99 <Tooltip key={option.decision}>
100 <TooltipTrigger render={item} />
101 <TooltipPopup side="top" className="max-w-64 text-xs leading-snug">
102 {option.warning}
103 </TooltipPopup>
104 </Tooltip>
105 ) : (
106 item
107 );
108 })}
109 </MenuPopup>
110 </Menu>
111 ) : null}
73112 </>
74113 );
75114});
apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx+1-17
......@@ -19,19 +19,7 @@ describe("ComposerPendingApprovalPanel", () => {
1919 />,
2020 );
2121
22 expect(markup).toContain('data-approval-detail="complete"');
23 expect(markup).toContain('aria-label="Command"');
24 expect(markup).toContain('role="group"');
25 expect(markup).toContain('tabindex="0"');
2622 expect(markup).toContain(detail);
27 expect(markup).toContain("max-h-20");
28 expect(markup).toContain("overflow-auto");
29 expect(markup).toContain("whitespace-pre");
30 expect(markup).toContain("[scrollbar-width:thin]");
31 expect(markup).toContain("[&amp;::-webkit-scrollbar]:h-1.5");
32 expect(markup).not.toContain("truncate");
33 expect(markup).not.toContain("line-clamp");
34 expect(markup).toContain("min-w-0");
3523 expect(markup).not.toContain("Command approval requested");
3624 });
3725
......@@ -65,13 +53,11 @@ describe("ComposerPendingApprovalPanel", () => {
6553 />,
6654 );
6755
68 expect(markup).toContain('aria-label="App access approval"');
69 expect(markup).toContain('aria-label="App access request"');
7056 expect(markup).toContain(">Safari<");
7157 expect(markup).toContain("Allow ChatGPT to use Safari?");
7258 });
7359
74 it("limits long app names so the complete approval message stays readable", () => {
60 it("preserves the full app name and approval message", () => {
7561 const appName = "A".repeat(200);
7662 const detail = "Allow ChatGPT to access the selected application?";
7763 const markup = renderToStaticMarkup(
......@@ -87,9 +73,7 @@ describe("ComposerPendingApprovalPanel", () => {
8773 />,
8874 );
8975
90 expect(markup).toContain("max-w-32 shrink truncate");
9176 expect(markup).toContain(appName);
92 expect(markup).toContain('data-approval-detail="complete"');
9377 expect(markup).toContain(detail);
9478 });
9579});
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+17-14
......@@ -13,6 +13,7 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
1313 pendingCount,
1414 className,
1515}: ComposerPendingApprovalPanelProps) {
16 const Detail = approval.requestKind === "mcp-elicitation" ? "span" : "code";
1617 const fallbackLabel =
1718 approval.requestKind === "mcp-elicitation"
1819 ? "App access approval"
......@@ -33,27 +34,29 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
3334 return (
3435 <span
3536 aria-label={fallbackLabel}
36 className={cn("flex min-w-0 flex-1 items-center gap-2", className)}
37 className={cn("flex min-w-0 flex-1 flex-col items-start gap-1", className)}
3738 role="group"
3839 >
39 {approval.appName ? (
40 <span className="max-w-32 shrink truncate text-[11px] font-medium text-foreground">
41 {approval.appName}
42 </span>
43 ) : null}
44 <code
40 <span className="flex w-full min-w-0 items-center gap-2 text-[11px] text-muted-foreground">
41 <span className="shrink-0 font-medium text-warning">{fallbackLabel}</span>
42 {approval.appName ? <span className="min-w-0 truncate">{approval.appName}</span> : null}
43 {pendingCount > 1 ? (
44 <span className="ml-auto shrink-0 tabular-nums">1/{pendingCount}</span>
45 ) : null}
46 </span>
47 <Detail
4548 aria-label={detailAriaLabel}
46 className="block max-h-20 min-w-0 flex-1 overflow-auto whitespace-pre font-mono text-[11px] text-foreground/85 [scrollbar-width:thin] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 [&::-webkit-scrollbar]:h-1.5"
49 className={cn(
50 "block max-h-20 w-full min-w-0 overflow-auto text-xs text-foreground [scrollbar-width:thin] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 [&::-webkit-scrollbar]:h-1.5",
51 approval.requestKind === "mcp-elicitation"
52 ? "whitespace-pre-wrap font-sans wrap-break-word"
53 : "whitespace-pre font-mono",
54 )}
4755 data-approval-detail="complete"
4856 tabIndex={0}
4957 >
5058 {approval.detail || fallbackLabel}
51 </code>
52 {pendingCount > 1 ? (
53 <span className="shrink-0 text-[10px] font-medium text-muted-foreground tabular-nums">
54 1/{pendingCount}
55 </span>
56 ) : null}
59 </Detail>
5760 </span>
5861 );
5962});
apps/web/src/components/ui/menu.tsx+8-1
......@@ -5,6 +5,7 @@ import { CheckIcon, ChevronRightIcon } from "lucide-react";
55import type * as React from "react";
66
77import { cn } from "~/lib/utils";
8import { buttonVariants } from "./button";
89
910const MenuCreateHandle = MenuPrimitive.createHandle;
1011
......@@ -81,12 +82,18 @@ function MenuItem({
8182 ...props
8283}: MenuPrimitive.Item.Props & {
8384 inset?: boolean;
84 variant?: "default" | "destructive";
85 variant?: "default" | "destructive" | "ghost";
8586}) {
8687 return (
8788 <MenuPrimitive.Item
8889 className={cn(
8990 "[&>svg]:-mx-0.5 flex min-h-8 cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-highlighted:bg-accent data-inset:ps-8 data-[variant=destructive]:text-destructive-foreground data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&>svg:not([class*='opacity-'])]:opacity-80 [&>svg:not([class*='size-'])]:size-4.5 sm:[&>svg:not([class*='size-'])]:size-4 [&>svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:[&>svg:not([class*='text-'])]:text-current [&>svg]:pointer-events-none [&>svg]:shrink-0",
91 variant === "ghost" &&
92 buttonVariants({
93 variant: "ghost",
94 size: "compact",
95 className: "h-auto min-h-7 w-full sm:text-xs",
96 }),
9097 className,
9198 )}
9299 data-inset={inset}