Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,7 @@ import {
PaperclipIcon,
PencilRulerIcon,
PlayIcon,
ShieldIcon,
XIcon,
} from "lucide-react";
import { proposedPlanTitle } from "../../proposedPlan";
Expand Down Expand Up @@ -6003,13 +6004,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
<ComposerBanner.Root
data-chat-composer-top-drawer="true"
variant={activePendingApproval ? "warning" : "info"}
density={activePendingApproval ? "spacious" : "default"}
>
{activePendingApproval ? (
<ComposerBanner.Row
layout="wrap-actions"
layout="approval"
data-chat-composer-collapsed-controls="true"
>
<ComposerBanner.Icon />
<ComposerBanner.Icon>
<ShieldIcon />
</ComposerBanner.Icon>
<ComposerBanner.Content>
<ComposerPendingApprovalPanel
approval={activePendingApproval}
Expand Down Expand Up @@ -6673,6 +6677,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
showMobilePendingAnswerActions && "max-sm:pb-11",
isComposerResting &&
"max-h-8 min-h-8 overflow-hidden whitespace-pre! leading-8",
isComposerApprovalState && "min-h-8",
)}
placeholderClassName={cn(
isComposerResting &&
Expand All @@ -6688,8 +6693,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onPaste={onComposerPaste}
placeholder={
isComposerApprovalState
? (activePendingApproval?.detail ??
"Resolve this approval request to continue")
? "Resolve this approval request to continue"
: activePendingProgress
? isChoiceOnlyPendingQuestion
? "Choose an option above"
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/components/chat/ComposerBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ function Root({
width = "fill",
...props
}: ComponentProps<"div"> & {
density?: "default" | "comfortable";
density?: "default" | "comfortable" | "spacious";
placement?: "attached" | "floating";
variant?: ComposerBannerVariant;
width?: "fill" | "content";
Expand All @@ -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,
)}
Expand All @@ -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(
Expand All @@ -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",
Expand All @@ -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}
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ComposerPendingApprovalActions
requestId={ApprovalRequestId.make("approval-1")}
Expand All @@ -14,15 +14,13 @@ describe("ComposerPendingApprovalActions", () => {
/>,
);

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(
<ComposerPendingApprovalActions
requestId={ApprovalRequestId.make("approval-safari")}
Expand All @@ -36,48 +34,27 @@ describe("ComposerPendingApprovalActions", () => {
/>,
);

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(
<ComposerPendingApprovalActions
requestId={ApprovalRequestId.make("approval-1")}
isResponding={false}
options={[
{ decision: "accept", label: "Allow once" },
{
decision: "acceptForSession",
label: "Allow for this thread",
warning: "Untrusted files could re-run this action without asking.",
},
{ decision: "decline", label: "Deny" },
]}
onRespondToApproval={async () => 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(
<ComposerPendingApprovalActions
requestId={ApprovalRequestId.make("approval-long-label")}
isResponding={false}
options={[{ decision: "acceptAlways", label }]}
onRespondToApproval={async () => 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<");
});
});
71 changes: 55 additions & 16 deletions apps/web/src/components/chat/ComposerPendingApprovalActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,7 +20,6 @@ interface ComposerPendingApprovalActionsProps {
) => Promise<unknown>;
}

const APPROVAL_ACTION_CLASS_NAME = "font-normal";
const DEFAULT_APPROVAL_OPTIONS = [
{ decision: "cancel", label: "Cancel" },
{ decision: "decline", label: "Decline" },
Expand All @@ -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 = (
<Button
key={option.decision}
size="micro"
variant="ghost-muted"
className={`${APPROVAL_ACTION_CLASS_NAME}${
option.decision === "decline"
? " text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground"
: option.decision === "accept"
? " text-foreground"
: option.warning
? " text-warning"
: ""
}`}
size="xs"
variant={option.decision === "accept" ? "default" : "outline"}
disabled={isResponding}
aria-description={option.warning}
onClick={() => void onRespondToApproval(requestId, option.decision)}
Expand All @@ -57,8 +56,6 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
<span className="max-w-40 truncate">{option.label}</span>
</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 ? (
<Tooltip key={option.decision}>
<TooltipTrigger render={button} />
Expand All @@ -70,6 +67,48 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
button
);
})}
{moreOptions.length > 0 ? (
<Menu>
<MenuTrigger
disabled={isResponding}
render={<Button size="icon-xs" variant="outline" aria-label="More approval options" />}
>
<EllipsisIcon />
</MenuTrigger>
<MenuPopup
{...composerFloatingLayerProps}
side="top"
align="end"
className="w-56 max-w-[calc(100vw-2rem)]"
>
{moreOptions.map((option) => {
const item = (
<MenuItem
key={option.decision}
disabled={isResponding}
aria-description={option.warning}
onClick={() => void onRespondToApproval(requestId, option.decision)}
variant="ghost"
className="mb-1 last:mb-0"
>
{option.warning ? <TriangleAlertIcon className="size-3 text-warning" /> : null}
<span className="min-w-0 whitespace-normal wrap-break-word">{option.label}</span>
</MenuItem>
);
return option.warning ? (
<Tooltip key={option.decision}>
<TooltipTrigger render={item} />
<TooltipPopup side="top" className="max-w-64 text-xs leading-snug">
{option.warning}
</TooltipPopup>
</Tooltip>
) : (
item
);
})}
</MenuPopup>
</Menu>
) : null}
</>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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("[&amp;::-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");
});

Expand Down Expand Up @@ -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(
Expand All @@ -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);
});
});
31 changes: 17 additions & 14 deletions apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -33,27 +34,29 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
return (
<span
aria-label={fallbackLabel}
className={cn("flex min-w-0 flex-1 items-center gap-2", className)}
className={cn("flex min-w-0 flex-1 flex-col items-start gap-1", className)}
role="group"
>
{approval.appName ? (
<span className="max-w-32 shrink truncate text-[11px] font-medium text-foreground">
{approval.appName}
</span>
) : null}
<code
<span className="flex w-full min-w-0 items-center gap-2 text-[11px] text-muted-foreground">
<span className="shrink-0 font-medium text-warning">{fallbackLabel}</span>
{approval.appName ? <span className="min-w-0 truncate">{approval.appName}</span> : null}
{pendingCount > 1 ? (
<span className="ml-auto shrink-0 tabular-nums">1/{pendingCount}</span>
) : null}
</span>
<Detail
aria-label={detailAriaLabel}
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"
className={cn(
"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",
approval.requestKind === "mcp-elicitation"
? "whitespace-pre-wrap font-sans wrap-break-word"
: "whitespace-pre font-mono",
)}
data-approval-detail="complete"
tabIndex={0}
>
{approval.detail || fallbackLabel}
</code>
{pendingCount > 1 ? (
<span className="shrink-0 text-[10px] font-medium text-muted-foreground tabular-nums">
1/{pendingCount}
</span>
) : null}
</Detail>
</span>
);
});
Loading
Loading