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 {...@@ -933,6 +933,7 @@ import {
933 PaperclipIcon,933 PaperclipIcon,
934 PencilRulerIcon,934 PencilRulerIcon,
935 PlayIcon,935 PlayIcon,
936 ShieldIcon,
936 XIcon,937 XIcon,
937} from "lucide-react";938} from "lucide-react";
938import { proposedPlanTitle } from "../../proposedPlan";939import { proposedPlanTitle } from "../../proposedPlan";
...@@ -6042,13 +6043,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6042,13 +6043,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6042 <ComposerBanner.Root6043 <ComposerBanner.Root
6043 data-chat-composer-top-drawer="true"6044 data-chat-composer-top-drawer="true"
6044 variant={activePendingApproval ? "warning" : "info"}6045 variant={activePendingApproval ? "warning" : "info"}
6046 density={activePendingApproval ? "spacious" : "default"}
6045 >6047 >
6046 {activePendingApproval ? (6048 {activePendingApproval ? (
6047 <ComposerBanner.Row6049 <ComposerBanner.Row
6048 layout="wrap-actions"6050 layout="approval"
6049 data-chat-composer-collapsed-controls="true"6051 data-chat-composer-collapsed-controls="true"
6050 >6052 >
6051 <ComposerBanner.Icon />6053 <ComposerBanner.Icon>
6054 <ShieldIcon />
6055 </ComposerBanner.Icon>
6052 <ComposerBanner.Content>6056 <ComposerBanner.Content>
6053 <ComposerPendingApprovalPanel6057 <ComposerPendingApprovalPanel
6054 approval={activePendingApproval}6058 approval={activePendingApproval}
...@@ -6712,6 +6716,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6712,6 +6716,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6712 showMobilePendingAnswerActions && "max-sm:pb-11",6716 showMobilePendingAnswerActions && "max-sm:pb-11",
6713 isComposerResting &&6717 isComposerResting &&
6714 "max-h-8 min-h-8 overflow-hidden whitespace-pre! leading-8",6718 "max-h-8 min-h-8 overflow-hidden whitespace-pre! leading-8",
6719 isComposerApprovalState && "min-h-8",
6715 )}6720 )}
6716 placeholderClassName={cn(6721 placeholderClassName={cn(
6717 isComposerResting &&6722 isComposerResting &&
...@@ -6727,8 +6732,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6727,8 +6732,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6727 onPaste={onComposerPaste}6732 onPaste={onComposerPaste}
6728 placeholder={6733 placeholder={
6729 isComposerApprovalState6734 isComposerApprovalState
6730 ? (activePendingApproval?.detail ??6735 ? "Resolve this approval request to continue"
6731 "Resolve this approval request to continue")
6732 : activePendingProgress6736 : activePendingProgress
6733 ? isChoiceOnlyPendingQuestion6737 ? isChoiceOnlyPendingQuestion
6734 ? "Choose an option above"6738 ? "Choose an option above"
apps/web/src/components/chat/ComposerBanner.tsx+7-2
...@@ -153,7 +153,7 @@ function Root({...@@ -153,7 +153,7 @@ function Root({
153 width = "fill",153 width = "fill",
154 ...props154 ...props
155}: ComponentProps<"div"> & {155}: ComponentProps<"div"> & {
156 density?: "default" | "comfortable";156 density?: "default" | "comfortable" | "spacious";
157 placement?: "attached" | "floating";157 placement?: "attached" | "floating";
158 variant?: ComposerBannerVariant;158 variant?: ComposerBannerVariant;
159 width?: "fill" | "content";159 width?: "fill" | "content";
...@@ -163,6 +163,7 @@ function Root({...@@ -163,6 +163,7 @@ function Root({
163 className={cn(163 className={cn(
164 "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)]",164 "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)]",
165 density === "comfortable" && "[--composer-banner-padding-block:--spacing(1.25)]",165 density === "comfortable" && "[--composer-banner-padding-block:--spacing(1.25)]",
166 density === "spacious" && "px-3 [--composer-banner-padding-block:--spacing(3)]",
166 width === "content" ? "w-fit max-w-full flex-none" : "@container",167 width === "content" ? "w-fit max-w-full flex-none" : "@container",
167 className,168 className,
168 )}169 )}
...@@ -182,7 +183,7 @@ function Row({...@@ -182,7 +183,7 @@ function Row({
182 layout = "inline",183 layout = "inline",
183 ...props184 ...props
184}: useRender.ComponentProps<"div"> & {185}: useRender.ComponentProps<"div"> & {
185 layout?: "inline" | "wrap-actions" | "wrap-actions-narrow";186 layout?: "inline" | "wrap-actions" | "wrap-actions-narrow" | "approval";
186}) {187}) {
187 const rowProps = {188 const rowProps = {
188 className: cn(189 className: cn(
...@@ -193,6 +194,7 @@ function Row({...@@ -193,6 +194,7 @@ function Row({
193 "@max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)",194 "@max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)",
194 layout === "wrap-actions-narrow" &&195 layout === "wrap-actions-narrow" &&
195 "@max-[320px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)",196 "@max-[320px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)",
197 layout === "approval" && "items-start gap-x-2 gap-y-3",
196 className,198 className,
197 ),199 ),
198 "data-composer-banner-row": "true",200 "data-composer-banner-row": "true",
...@@ -212,6 +214,7 @@ function Icon({ className, ...props }: ComponentProps<"span">) {...@@ -212,6 +214,7 @@ function Icon({ className, ...props }: ComponentProps<"span">) {
212 data-slot="composer-banner-icon"214 data-slot="composer-banner-icon"
213 className={cn(215 className={cn(
214 "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",216 "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",
215 className,218 className,
216 )}219 )}
217 {...props}220 {...props}
...@@ -225,6 +228,7 @@ function Content({ className, ...props }: ComponentProps<"span">) {...@@ -225,6 +228,7 @@ function Content({ className, ...props }: ComponentProps<"span">) {
225 data-slot="composer-banner-content"228 data-slot="composer-banner-content"
226 className={cn(229 className={cn(
227 "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0",230 "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",
228 "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",232 "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",
229 "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",233 "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",
230 className,234 className,
...@@ -252,6 +256,7 @@ function Actions({ className, ...props }: ComponentProps<"span">) {...@@ -252,6 +256,7 @@ function Actions({ className, ...props }: ComponentProps<"span">) {
252 data-slot="composer-banner-actions"256 data-slot="composer-banner-actions"
253 className={cn(257 className={cn(
254 "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1",258 "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",
255 "@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",260 "@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",
256 "@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",261 "@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",
257 className,262 className,
apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx+12-35
...@@ -5,7 +5,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -5,7 +5,7 @@ import { describe, expect, it } from "vite-plus/test";
5import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";5import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
66
7describe("ComposerPendingApprovalActions", () => {7describe("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", () => {
9 const markup = renderToStaticMarkup(9 const markup = renderToStaticMarkup(
10 <ComposerPendingApprovalActions10 <ComposerPendingApprovalActions
11 requestId={ApprovalRequestId.make("approval-1")}11 requestId={ApprovalRequestId.make("approval-1")}
...@@ -14,15 +14,13 @@ describe("ComposerPendingApprovalActions", () => {...@@ -14,15 +14,13 @@ describe("ComposerPendingApprovalActions", () => {
14 />,14 />,
15 );15 );
1616
17 expect(markup).toContain(">Cancel<");17 expect(markup).toContain(">Decline<");
18 expect(markup).toContain("Always allow this session");18 expect(markup).toContain(">Approve<");
19 expect(markup).not.toContain(">Always allow<");19 expect(markup).not.toContain(">Cancel<");
20 expect(markup).toContain("h-5");20 expect(markup).not.toContain("Always allow this session");
21 expect(markup).toContain("sm:text-[11px]");
22 expect(markup).not.toContain("sm:h-6");
23 });21 });
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", () => {
26 const markup = renderToStaticMarkup(24 const markup = renderToStaticMarkup(
27 <ComposerPendingApprovalActions25 <ComposerPendingApprovalActions
28 requestId={ApprovalRequestId.make("approval-safari")}26 requestId={ApprovalRequestId.make("approval-safari")}
...@@ -36,48 +34,27 @@ describe("ComposerPendingApprovalActions", () => {...@@ -36,48 +34,27 @@ describe("ComposerPendingApprovalActions", () => {
36 />,34 />,
37 );35 );
3836
39 expect(markup).toContain("Always allow Safari");37 expect(markup).not.toContain("Always allow Safari");
40 expect(markup).toContain(">Approve<");38 expect(markup).toContain(">Approve<");
41 expect(markup).not.toContain("Always allow this session");39 expect(markup).not.toContain("Always allow this session");
42 });40 });
4341
44 it("marks an option that carries a provider warning", () => {42 it("preserves provider labels for the main decisions", () => {
45 const markup = renderToStaticMarkup(43 const markup = renderToStaticMarkup(
46 <ComposerPendingApprovalActions44 <ComposerPendingApprovalActions
47 requestId={ApprovalRequestId.make("approval-1")}45 requestId={ApprovalRequestId.make("approval-1")}
48 isResponding={false}46 isResponding={false}
49 options={[47 options={[
50 { decision: "accept", label: "Allow once" },48 { 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 },
56 { decision: "decline", label: "Deny" },49 { decision: "decline", label: "Deny" },
57 ]}50 ]}
58 onRespondToApproval={async () => undefined}51 onRespondToApproval={async () => undefined}
59 />,52 />,
60 );53 );
6154
62 expect(markup).toContain(55 expect(markup).toContain("Allow once");
63 'aria-description="Untrusted files could re-run this action without asking."',56 expect(markup).toContain("Deny");
64 );57 expect(markup).not.toContain(">Approve<");
65 expect(markup).toContain("text-warning");58 expect(markup).not.toContain(">Decline<");
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);
82 });59 });
83});60});
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+55-16
...@@ -4,9 +4,11 @@ import {...@@ -4,9 +4,11 @@ import {
4 type ProviderApprovalOption,4 type ProviderApprovalOption,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { memo } from "react";6import { memo } from "react";
7import { TriangleAlertIcon } from "lucide-react";7import { EllipsisIcon, TriangleAlertIcon } from "lucide-react";
8import { Button } from "../ui/button";8import { Button } from "../ui/button";
9import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";10import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
11import { composerFloatingLayerProps } from "./composerEventScope";
1012
11interface ComposerPendingApprovalActionsProps {13interface ComposerPendingApprovalActionsProps {
12 requestId: ApprovalRequestId;14 requestId: ApprovalRequestId;
...@@ -18,7 +20,6 @@ interface ComposerPendingApprovalActionsProps {...@@ -18,7 +20,6 @@ interface ComposerPendingApprovalActionsProps {
18 ) => Promise<unknown>;20 ) => Promise<unknown>;
19}21}
2022
21const APPROVAL_ACTION_CLASS_NAME = "font-normal";
22const DEFAULT_APPROVAL_OPTIONS = [23const DEFAULT_APPROVAL_OPTIONS = [
23 { decision: "cancel", label: "Cancel" },24 { decision: "cancel", label: "Cancel" },
24 { decision: "decline", label: "Decline" },25 { decision: "decline", label: "Decline" },
...@@ -32,23 +33,21 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro...@@ -32,23 +33,21 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
32 options = DEFAULT_APPROVAL_OPTIONS,33 options = DEFAULT_APPROVAL_OPTIONS,
33 onRespondToApproval,34 onRespondToApproval,
34}: ComposerPendingApprovalActionsProps) {35}: 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
35 return (43 return (
36 <>44 <>
37 {options.map((option) => {45 {primaryOptions.map((option) => {
38 const button = (46 const button = (
39 <Button47 <Button
40 key={option.decision}48 key={option.decision}
41 size="micro"49 size="xs"
42 variant="ghost-muted"50 variant={option.decision === "accept" ? "default" : "outline"}
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 }`}
52 disabled={isResponding}51 disabled={isResponding}
53 aria-description={option.warning}52 aria-description={option.warning}
54 onClick={() => void onRespondToApproval(requestId, option.decision)}53 onClick={() => void onRespondToApproval(requestId, option.decision)}
...@@ -57,8 +56,6 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro...@@ -57,8 +56,6 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
57 <span className="max-w-40 truncate">{option.label}</span>56 <span className="max-w-40 truncate">{option.label}</span>
58 </Button>57 </Button>
59 );58 );
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.
62 return option.warning ? (59 return option.warning ? (
63 <Tooltip key={option.decision}>60 <Tooltip key={option.decision}>
64 <TooltipTrigger render={button} />61 <TooltipTrigger render={button} />
...@@ -70,6 +67,48 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro...@@ -70,6 +67,48 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
70 button67 button
71 );68 );
72 })}69 })}
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}
73 </>112 </>
74 );113 );
75});114});
apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx+1-17
...@@ -19,19 +19,7 @@ describe("ComposerPendingApprovalPanel", () => {...@@ -19,19 +19,7 @@ describe("ComposerPendingApprovalPanel", () => {
19 />,19 />,
20 );20 );
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"');
26 expect(markup).toContain(detail);22 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");
35 expect(markup).not.toContain("Command approval requested");23 expect(markup).not.toContain("Command approval requested");
36 });24 });
3725
...@@ -65,13 +53,11 @@ describe("ComposerPendingApprovalPanel", () => {...@@ -65,13 +53,11 @@ describe("ComposerPendingApprovalPanel", () => {
65 />,53 />,
66 );54 );
6755
68 expect(markup).toContain('aria-label="App access approval"');
69 expect(markup).toContain('aria-label="App access request"');
70 expect(markup).toContain(">Safari<");56 expect(markup).toContain(">Safari<");
71 expect(markup).toContain("Allow ChatGPT to use Safari?");57 expect(markup).toContain("Allow ChatGPT to use Safari?");
72 });58 });
7359
74 it("limits long app names so the complete approval message stays readable", () => {60 it("preserves the full app name and approval message", () => {
75 const appName = "A".repeat(200);61 const appName = "A".repeat(200);
76 const detail = "Allow ChatGPT to access the selected application?";62 const detail = "Allow ChatGPT to access the selected application?";
77 const markup = renderToStaticMarkup(63 const markup = renderToStaticMarkup(
...@@ -87,9 +73,7 @@ describe("ComposerPendingApprovalPanel", () => {...@@ -87,9 +73,7 @@ describe("ComposerPendingApprovalPanel", () => {
87 />,73 />,
88 );74 );
8975
90 expect(markup).toContain("max-w-32 shrink truncate");
91 expect(markup).toContain(appName);76 expect(markup).toContain(appName);
92 expect(markup).toContain('data-approval-detail="complete"');
93 expect(markup).toContain(detail);77 expect(markup).toContain(detail);
94 });78 });
95});79});
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+17-14
...@@ -13,6 +13,7 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova...@@ -13,6 +13,7 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
13 pendingCount,13 pendingCount,
14 className,14 className,
15}: ComposerPendingApprovalPanelProps) {15}: ComposerPendingApprovalPanelProps) {
16 const Detail = approval.requestKind === "mcp-elicitation" ? "span" : "code";
16 const fallbackLabel =17 const fallbackLabel =
17 approval.requestKind === "mcp-elicitation"18 approval.requestKind === "mcp-elicitation"
18 ? "App access approval"19 ? "App access approval"
...@@ -33,27 +34,29 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova...@@ -33,27 +34,29 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
33 return (34 return (
34 <span35 <span
35 aria-label={fallbackLabel}36 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)}
37 role="group"38 role="group"
38 >39 >
39 {approval.appName ? (40 <span className="flex w-full min-w-0 items-center gap-2 text-[11px] text-muted-foreground">
40 <span className="max-w-32 shrink truncate text-[11px] font-medium text-foreground">41 <span className="shrink-0 font-medium text-warning">{fallbackLabel}</span>
41 {approval.appName}42 {approval.appName ? <span className="min-w-0 truncate">{approval.appName}</span> : null}
42 </span>43 {pendingCount > 1 ? (
43 ) : null}44 <span className="ml-auto shrink-0 tabular-nums">1/{pendingCount}</span>
44 <code45 ) : null}
46 </span>
47 <Detail
45 aria-label={detailAriaLabel}48 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 )}
47 data-approval-detail="complete"55 data-approval-detail="complete"
48 tabIndex={0}56 tabIndex={0}
49 >57 >
50 {approval.detail || fallbackLabel}58 {approval.detail || fallbackLabel}
51 </code>59 </Detail>
52 {pendingCount > 1 ? (
53 <span className="shrink-0 text-[10px] font-medium text-muted-foreground tabular-nums">
54 1/{pendingCount}
55 </span>
56 ) : null}
57 </span>60 </span>
58 );61 );
59});62});
apps/web/src/components/ui/menu.tsx+8-1
...@@ -5,6 +5,7 @@ import { CheckIcon, ChevronRightIcon } from "lucide-react";...@@ -5,6 +5,7 @@ import { CheckIcon, ChevronRightIcon } from "lucide-react";
5import type * as React from "react";5import type * as React from "react";
66
7import { cn } from "~/lib/utils";7import { cn } from "~/lib/utils";
8import { buttonVariants } from "./button";
89
9const MenuCreateHandle = MenuPrimitive.createHandle;10const MenuCreateHandle = MenuPrimitive.createHandle;
1011
...@@ -81,12 +82,18 @@ function MenuItem({...@@ -81,12 +82,18 @@ function MenuItem({
81 ...props82 ...props
82}: MenuPrimitive.Item.Props & {83}: MenuPrimitive.Item.Props & {
83 inset?: boolean;84 inset?: boolean;
84 variant?: "default" | "destructive";85 variant?: "default" | "destructive" | "ghost";
85}) {86}) {
86 return (87 return (
87 <MenuPrimitive.Item88 <MenuPrimitive.Item
88 className={cn(89 className={cn(
89 "[&>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",90 "[&>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 }),
90 className,97 className,
91 )}98 )}
92 data-inset={inset}99 data-inset={inset}