diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f7d3ac540ccc..6294d721ef51 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -930,6 +930,7 @@ import { PaperclipIcon, PencilRulerIcon, PlayIcon, + ShieldIcon, XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; @@ -6003,13 +6004,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {activePendingApproval ? ( - + + + & { - density?: "default" | "comfortable"; + density?: "default" | "comfortable" | "spacious"; placement?: "attached" | "floating"; variant?: ComposerBannerVariant; width?: "fill" | "content"; @@ -163,6 +163,7 @@ function Root({ className={cn( "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)]", density === "comfortable" && "[--composer-banner-padding-block:--spacing(1.25)]", + density === "spacious" && "px-3 [--composer-banner-padding-block:--spacing(3)]", width === "content" ? "w-fit max-w-full flex-none" : "@container", className, )} @@ -182,7 +183,7 @@ function Row({ layout = "inline", ...props }: useRender.ComponentProps<"div"> & { - layout?: "inline" | "wrap-actions" | "wrap-actions-narrow"; + layout?: "inline" | "wrap-actions" | "wrap-actions-narrow" | "approval"; }) { const rowProps = { className: cn( @@ -193,6 +194,7 @@ function Row({ "@max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)", layout === "wrap-actions-narrow" && "@max-[320px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column)", + layout === "approval" && "items-start gap-x-2 gap-y-3", className, ), "data-composer-banner-row": "true", @@ -212,6 +214,7 @@ function Icon({ className, ...props }: ComponentProps<"span">) { data-slot="composer-banner-icon" className={cn( "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", + "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", className, )} {...props} @@ -225,6 +228,7 @@ function Content({ className, ...props }: ComponentProps<"span">) { data-slot="composer-banner-content" className={cn( "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0", + "@max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4", "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", "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", className, @@ -252,6 +256,7 @@ function Actions({ className, ...props }: ComponentProps<"span">) { data-slot="composer-banner-actions" className={cn( "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1", + "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", "@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", "@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", className, diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx index a68ea539ebed..0db4b9f76aaf 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; describe("ComposerPendingApprovalActions", () => { - it("states that the persistent approval lasts for this session", () => { + it("keeps the main decisions visible and secondary decisions in the menu", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain(">Cancel<"); - expect(markup).toContain("Always allow this session"); - expect(markup).not.toContain(">Always allow<"); - expect(markup).toContain("h-5"); - expect(markup).toContain("sm:text-[11px]"); - expect(markup).not.toContain("sm:h-6"); + expect(markup).toContain(">Decline<"); + expect(markup).toContain(">Approve<"); + expect(markup).not.toContain(">Cancel<"); + expect(markup).not.toContain("Always allow this session"); }); - it("shows only the approval choices advertised by an MCP server", () => { + it("keeps secondary provider labels out of the compact action row", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("Always allow Safari"); + expect(markup).not.toContain("Always allow Safari"); expect(markup).toContain(">Approve<"); expect(markup).not.toContain("Always allow this session"); }); - it("marks an option that carries a provider warning", () => { + it("preserves provider labels for the main decisions", () => { const markup = renderToStaticMarkup( undefined} />, ); - expect(markup).toContain( - 'aria-description="Untrusted files could re-run this action without asking."', - ); - expect(markup).toContain("text-warning"); - expect(markup).toContain("Allow for this thread"); - }); - - it("limits provider-supplied approval labels so narrow rows can wrap", () => { - const label = "Allow ".repeat(40).trim(); - const markup = renderToStaticMarkup( - undefined} - />, - ); - - expect(markup).toContain('class="max-w-40 truncate"'); - expect(markup).toContain(label); + expect(markup).toContain("Allow once"); + expect(markup).toContain("Deny"); + expect(markup).not.toContain(">Approve<"); + expect(markup).not.toContain(">Decline<"); }); }); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx index 33f5afe50d75..743d846dc5e2 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx @@ -4,9 +4,11 @@ import { type ProviderApprovalOption, } from "@t3tools/contracts"; import { memo } from "react"; -import { TriangleAlertIcon } from "lucide-react"; +import { EllipsisIcon, TriangleAlertIcon } from "lucide-react"; import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { composerFloatingLayerProps } from "./composerEventScope"; interface ComposerPendingApprovalActionsProps { requestId: ApprovalRequestId; @@ -18,7 +20,6 @@ interface ComposerPendingApprovalActionsProps { ) => Promise; } -const APPROVAL_ACTION_CLASS_NAME = "font-normal"; const DEFAULT_APPROVAL_OPTIONS = [ { decision: "cancel", label: "Cancel" }, { decision: "decline", label: "Decline" }, @@ -32,23 +33,21 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro options = DEFAULT_APPROVAL_OPTIONS, onRespondToApproval, }: ComposerPendingApprovalActionsProps) { + const primaryOptions = options.filter( + (option) => option.decision === "decline" || option.decision === "accept", + ); + const moreOptions = options.filter( + (option) => option.decision !== "decline" && option.decision !== "accept", + ); + return ( <> - {options.map((option) => { + {primaryOptions.map((option) => { const button = ( ); - // A provider caution, such as a prompt injection warning on "allow - // always", rides along as a tooltip so the row stays one line. return option.warning ? ( @@ -70,6 +67,48 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro button ); })} + {moreOptions.length > 0 ? ( + + } + > + + + + {moreOptions.map((option) => { + const item = ( + void onRespondToApproval(requestId, option.decision)} + variant="ghost" + className="mb-1 last:mb-0" + > + {option.warning ? : null} + {option.label} + + ); + return option.warning ? ( + + + + {option.warning} + + + ) : ( + item + ); + })} + + + ) : null} ); }); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx index a7ef6bc7d5cb..7a32fdfe95d6 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx @@ -19,19 +19,7 @@ describe("ComposerPendingApprovalPanel", () => { />, ); - expect(markup).toContain('data-approval-detail="complete"'); - expect(markup).toContain('aria-label="Command"'); - expect(markup).toContain('role="group"'); - expect(markup).toContain('tabindex="0"'); expect(markup).toContain(detail); - expect(markup).toContain("max-h-20"); - expect(markup).toContain("overflow-auto"); - expect(markup).toContain("whitespace-pre"); - expect(markup).toContain("[scrollbar-width:thin]"); - expect(markup).toContain("[&::-webkit-scrollbar]:h-1.5"); - expect(markup).not.toContain("truncate"); - expect(markup).not.toContain("line-clamp"); - expect(markup).toContain("min-w-0"); expect(markup).not.toContain("Command approval requested"); }); @@ -65,13 +53,11 @@ describe("ComposerPendingApprovalPanel", () => { />, ); - expect(markup).toContain('aria-label="App access approval"'); - expect(markup).toContain('aria-label="App access request"'); expect(markup).toContain(">Safari<"); expect(markup).toContain("Allow ChatGPT to use Safari?"); }); - it("limits long app names so the complete approval message stays readable", () => { + it("preserves the full app name and approval message", () => { const appName = "A".repeat(200); const detail = "Allow ChatGPT to access the selected application?"; const markup = renderToStaticMarkup( @@ -87,9 +73,7 @@ describe("ComposerPendingApprovalPanel", () => { />, ); - expect(markup).toContain("max-w-32 shrink truncate"); expect(markup).toContain(appName); - expect(markup).toContain('data-approval-detail="complete"'); expect(markup).toContain(detail); }); }); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index deb5ba54c53c..8065556cebc5 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -13,6 +13,7 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova pendingCount, className, }: ComposerPendingApprovalPanelProps) { + const Detail = approval.requestKind === "mcp-elicitation" ? "span" : "code"; const fallbackLabel = approval.requestKind === "mcp-elicitation" ? "App access approval" @@ -33,27 +34,29 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova return ( - {approval.appName ? ( - - {approval.appName} - - ) : null} - + {fallbackLabel} + {approval.appName ? {approval.appName} : null} + {pendingCount > 1 ? ( + 1/{pendingCount} + ) : null} + + {approval.detail || fallbackLabel} - - {pendingCount > 1 ? ( - - 1/{pendingCount} - - ) : null} + ); }); diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index fbdcbc03480a..6f35c1dc2784 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -5,6 +5,7 @@ import { CheckIcon, ChevronRightIcon } from "lucide-react"; import type * as React from "react"; import { cn } from "~/lib/utils"; +import { buttonVariants } from "./button"; const MenuCreateHandle = MenuPrimitive.createHandle; @@ -81,12 +82,18 @@ function MenuItem({ ...props }: MenuPrimitive.Item.Props & { inset?: boolean; - variant?: "default" | "destructive"; + variant?: "default" | "destructive" | "ghost"; }) { return ( 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", + variant === "ghost" && + buttonVariants({ + variant: "ghost", + size: "compact", + className: "h-auto min-h-7 w-full sm:text-xs", + }), className, )} data-inset={inset}