| author | |
| committer | |
| log | 01e64193d9d2abfae8f7b3d20f66824e38397bd6 |
| tree | f2b24958ae1c6afcb655e22b4c2563b88624a5ba |
| parent | f1b497a5552abf4616a7477b730b78a1274f3db4 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 938 | import { proposedPlanTitle } from "../../proposedPlan"; | 939 | import { 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.Root | 6043 | <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.Row | 6049 | <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 | <ComposerPendingApprovalPanel | 6057 | <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 | isComposerApprovalState | 6734 | isComposerApprovalState |
| 6730 | ? (activePendingApproval?.detail ?? | 6735 | ? "Resolve this approval request to continue" |
| 6731 | "Resolve this approval request to continue") | ||
| 6732 | : activePendingProgress | 6736 | : activePendingProgress |
| 6733 | ? isChoiceOnlyPendingQuestion | 6737 | ? 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 | ...props | 154 | ...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 | ...props | 184 | ...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"; |
| 5 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; | 5 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; |
| 6 | 6 | ||
| 7 | describe("ComposerPendingApprovalActions", () => { | 7 | describe("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 | <ComposerPendingApprovalActions | 10 | <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 | ); |
| 16 | 16 | ||
| 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 | }); |
| 24 | 22 | ||
| 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 | <ComposerPendingApprovalActions | 25 | <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 | ); |
| 38 | 36 | ||
| 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 | }); |
| 43 | 41 | ||
| 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 | <ComposerPendingApprovalActions | 44 | <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 | ); |
| 61 | 54 | ||
| 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"; |
| 6 | import { memo } from "react"; | 6 | import { memo } from "react"; |
| 7 | import { TriangleAlertIcon } from "lucide-react"; | 7 | import { EllipsisIcon, TriangleAlertIcon } from "lucide-react"; |
| 8 | import { Button } from "../ui/button"; | 8 | import { Button } from "../ui/button"; |
| 9 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | ||
| 9 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 10 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 11 | import { composerFloatingLayerProps } from "./composerEventScope"; | ||
| 10 | 12 | ||
| 11 | interface ComposerPendingApprovalActionsProps { | 13 | interface 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 | } |
| 20 | 22 | ||
| 21 | const APPROVAL_ACTION_CLASS_NAME = "font-normal"; | ||
| 22 | const DEFAULT_APPROVAL_OPTIONS = [ | 23 | const 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 | <Button | 47 | <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 | button | 67 | 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 | ); |
| 21 | 21 | ||
| 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 | }); |
| 37 | 25 | ||
| ... | @@ -65,13 +53,11 @@ describe("ComposerPendingApprovalPanel", () => { | ... | @@ -65,13 +53,11 @@ describe("ComposerPendingApprovalPanel", () => { |
| 65 | />, | 53 | />, |
| 66 | ); | 54 | ); |
| 67 | 55 | ||
| 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 | }); |
| 73 | 59 | ||
| 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 | ); |
| 89 | 75 | ||
| 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 | <span | 35 | <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 | <code | 45 | ) : 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"; |
| 5 | import type * as React from "react"; | 5 | import type * as React from "react"; |
| 6 | 6 | ||
| 7 | import { cn } from "~/lib/utils"; | 7 | import { cn } from "~/lib/utils"; |
| 8 | import { buttonVariants } from "./button"; | ||
| 8 | 9 | ||
| 9 | const MenuCreateHandle = MenuPrimitive.createHandle; | 10 | const MenuCreateHandle = MenuPrimitive.createHandle; |
| 10 | 11 | ||
| ... | @@ -81,12 +82,18 @@ function MenuItem({ | ... | @@ -81,12 +82,18 @@ function MenuItem({ |
| 81 | ...props | 82 | ...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.Item | 88 | <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} |