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
53 changes: 24 additions & 29 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1100,14 +1100,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
render={
<ComposerControl
size={size}
className={cn(
"shrink-0 whitespace-nowrap",
props.interactionMode === "plan"
? "bg-accent text-accent-foreground hover:bg-accent/80"
: size === "xs"
? undefined
: "text-secondary-label hover:text-foreground",
)}
className="shrink-0 whitespace-nowrap"
aria-pressed={props.interactionMode === "plan"}
type="button"
onClick={props.onToggleInteractionMode}
aria-label={interactionModeTooltip}
Expand Down Expand Up @@ -1152,7 +1146,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
<ComposerSelectControl
data-composer-shortcut="composer.mode"
size={size}
className={size === "xs" ? undefined : "font-medium"}
aria-label="Runtime mode"
/>
}
Expand Down Expand Up @@ -4994,22 +4987,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
.slice(restingBlockDefs.length - restingHiddenBlockCount)
.map((def) => def.id);
const composerControls = showProviderUnavailable ? (
<Button
<ComposerControl
type="button"
size="sm"
variant="ghost"
disabled={!providerSetupInstanceId}
onClick={() => {
if (providerSetupInstanceId) {
onOpenProviderSetup(providerSetupInstanceId);
}
}}
data-chat-provider-unavailable="true"
className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3"
className="shrink-0"
>
<CircleAlertIcon className="size-4" />
{providerSetupInstanceId ? "Open provider settings" : "No provider available"}
</Button>
</ComposerControl>
) : (
<>
{composerControlsInStrip && restingControlsHaveLeadingContext ? (
Expand Down Expand Up @@ -6590,9 +6581,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
<TooltipTrigger
render={
<Button
variant="ghost"
variant="overlay"
size="icon-xs"
className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95"
className="absolute bottom-1 left-1"
onClick={() =>
retryAttachmentUpload({
environmentId,
Expand All @@ -6609,19 +6600,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
<TooltipPopup side="top">{upload.reason}</TooltipPopup>
</Tooltip>
)}
<Button
variant="ghost"
size="icon-xs"
{/* Snap-shot frames reveal their remove button on hover or focus. */}
<span
className={cn(
"absolute right-1 top-1 bg-background/80 hover:bg-background/90",
"absolute right-1 top-1 flex",
image.source?.kind === "snap-shot" &&
"opacity-0 transition-opacity pointer-coarse:opacity-100 focus-visible:opacity-100 group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100",
"opacity-0 transition-opacity pointer-coarse:opacity-100 group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100",
)}
onClick={() => removeComposerImage(image.id)}
aria-label={`Remove ${image.name}`}
>
<XIcon />
</Button>
<Button
variant="media-close"
size="icon-xs"
onClick={() => removeComposerImage(image.id)}
aria-label={`Remove ${image.name}`}
>
<XIcon />
</Button>
</span>
</SnapShotAttachmentFrame>
);
})
Expand Down Expand Up @@ -6685,9 +6680,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
<TooltipTrigger
render={
<Button
variant="ghost"
variant="overlay"
size="icon-xs"
className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95"
className="absolute bottom-1 left-1"
onClick={() =>
retryAttachmentUpload({
environmentId,
Expand All @@ -6705,9 +6700,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
</Tooltip>
)}
<Button
variant="ghost"
variant="media-close"
size="icon-xs"
className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
className="absolute right-1 top-1"
onClick={() => removeComposerFileFromDraft(file.id)}
aria-label={`Remove ${file.name}`}
>
Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/components/chat/CompactComposerControlsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
render={
<ComposerControl
size={size}
variant="ghost"
className={size === "xs" ? "shrink-0" : "shrink-0 px-2"}
className="shrink-0"
aria-label="More composer controls"
data-composer-shortcut={
props.traitsMenuContent ? "composer.mode composer.effort" : "composer.mode"
Expand Down
78 changes: 40 additions & 38 deletions apps/web/src/components/chat/ComposerControl.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,49 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { useRender } from "@base-ui/react/use-render";
import type { ComponentProps } from "react";
import { ChevronDownIcon, type LucideIcon } from "lucide-react";

import { cn } from "~/lib/utils";
import { Button } from "../ui/button";
import { SelectTrigger } from "../ui/select";
import { Separator } from "../ui/separator";

export type ComposerControlSize = "sm" | "xs";

type ComposerControlProps = Omit<ComponentProps<typeof Button>, "size"> & {
size?: ComposerControlSize;
};
/**
* The composer toolbar's control look. `sm` is the expanded toolbar; `xs` is the dimmer resting
* strip. `aria-pressed` marks a toggle that is on (plan mode). This is an app control, not a
* restyled Button, so it owns its classes.
*/
function composerControlClassName(size: ComposerControlSize, className?: string) {
return cn(
"relative inline-flex shrink-0 cursor-pointer items-center justify-center whitespace-nowrap rounded-[var(--control-radius)] border border-transparent text-base outline-none hover:bg-accent data-pressed:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&:active:not([aria-haspopup])]:scale-[0.97] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:-mx-0.5 [&_svg[data-composer-control-icon]]:mx-0 [&_svg:not([class*='text-'])]:text-[var(--control-icon-color)]",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared primitives over custom controls: this recreates ui/Button and ui/SelectTrigger styling instead of composing those primitives. Add the composer sizes/variants to the UI primitives and have ComposerControl delegate to them.

Suggested fix: No diff; the fix requires coordinated primitive variants and component changes.

Posted via Macroscope — UI Consistency

size === "xs"
? "h-7 gap-1 px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-sm [--control-icon-color:currentColor] hover:text-foreground/80 sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5 [&_svg[data-composer-control-chevron]]:ms-0 [&_svg[data-composer-control-chevron]]:-me-1"
: "h-7 gap-1.5 px-2.5 font-medium text-secondary-label [--control-icon-color:var(--contrast-muted-foreground)] hover:text-foreground sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4",
"aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-pressed:hover:bg-accent/80",
className,
);
}

type ComposerSelectControlProps = Omit<ComponentProps<typeof SelectTrigger>, "size"> & {
type ComposerControlProps = useRender.ComponentProps<"button"> & {
size?: ComposerControlSize;
};

const composerControlClassName =
"rounded-[var(--control-radius)] text-secondary-label transition-none hover:text-foreground [&_svg[data-composer-control-chevron]]:-mx-0.5 [&_svg[data-composer-control-icon]]:mx-0";
const expandedComposerControlClassName = "h-7 min-h-7 gap-1.5 px-2.5";
const restingComposerControlClassName =
"[--control-icon-color:currentColor] font-normal text-muted-foreground/70 hover:text-foreground/80 [&_svg[data-composer-control-chevron]]:-me-1 [&_svg[data-composer-control-chevron]]:ms-0";

export function ComposerControl({
className,
size = "sm",
variant = "ghost",
render,
...props
}: ComposerControlProps) {
return (
<Button
className={cn(
composerControlClassName,
size === "xs" ? restingComposerControlClassName : expandedComposerControlClassName,
className,
)}
size={size}
variant={variant}
{...props}
/>
);
const defaultProps = {
className: composerControlClassName(size, className),
type: render ? undefined : ("button" as const),
};
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(defaultProps, props),
render,
});
}

export function ComposerControlIcon({
Expand Down Expand Up @@ -105,21 +109,19 @@ export function ComposerControlSeparator({

export function ComposerSelectControl({
className,
children,
size = "sm",
variant = "ghost",
...props
}: ComposerSelectControlProps) {
}: Omit<SelectPrimitive.Trigger.Props, "className"> & {
className?: string | undefined;
size?: ComposerControlSize;
}) {
return (
<SelectTrigger
className={cn(
composerControlClassName,
size === "xs" ? restingComposerControlClassName : expandedComposerControlClassName,
className,
)}
icon={<ComposerControlChevron size={size} />}
size={size}
variant={variant}
{...props}
/>
<SelectPrimitive.Trigger className={composerControlClassName(size, className)} {...props}>
{children}
<SelectPrimitive.Icon>
<ComposerControlChevron size={size} />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
44 changes: 20 additions & 24 deletions apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ const formatPendingPrimaryActionLabel = (input: {
return input.questionIndex > 0 ? "Submit answers" : "Submit answer";
};

// The composer's labeled primary actions (Submit, Refine, Implement) share the send button's
// message-action pill, so they are composer-owned buttons rather than restyled Buttons.
const messageActionPillClassName =
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full bg-message-action font-medium text-base text-message-action-foreground shadow-xs shadow-message-action/24 outline-none hover:bg-message-action-hover focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 disabled:shadow-none sm:text-sm";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared primitives over custom controls: this class turns several raw <button> elements into product controls. Add a message-action variant/size to ui/Button and render these actions with Button instead.

Suggested fix: No diff; the fix requires adding a shared Button variant and updating multiple call sites.

Posted via Macroscope — UI Consistency


const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => {
event.preventDefault();
};
Expand Down Expand Up @@ -112,7 +117,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
<Button
size="icon-sm"
variant="outline"
className="rounded-full"
{...pointerFocusProps}
onClick={onPreviousPendingQuestion}
disabled={pendingAction.isResponding}
Expand All @@ -124,7 +128,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
<Button
size="sm"
variant="outline"
className="rounded-full"
{...pointerFocusProps}
onClick={onPreviousPendingQuestion}
disabled={pendingAction.isResponding}
Expand All @@ -133,13 +136,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
</Button>
)
) : null}
<Button
<button
type="submit"
size="sm"
className={cn(
"rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover",
compact ? "px-3" : "px-4",
)}
className={cn(messageActionPillClassName, "h-8 sm:h-7", compact ? "px-3" : "px-4")}
{...pointerFocusProps}
disabled={
isEnvironmentUnavailable ||
Expand All @@ -153,47 +152,44 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
isResponding: pendingAction.isResponding,
questionIndex: pendingAction.questionIndex,
})}
</Button>
</button>
</div>
);
}

if (showPlanFollowUpPrompt) {
if (promptHasText) {
return (
<Button
<button
type="submit"
size="sm"
className={cn(
"rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover",
compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8",
)}
className={cn(messageActionPillClassName, "h-9 sm:h-8", compact ? "px-3" : "px-4")}
{...pointerFocusProps}
disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
>
{isConnecting || isSendBusy ? "Sending..." : "Refine"}
</Button>
</button>
);
}

return (
<div data-chat-composer-implement-actions="true" className="flex items-center justify-end">
<Button
<button
type="submit"
size="sm"
className="h-9 rounded-l-full rounded-r-none bg-message-action px-4 text-message-action-foreground hover:bg-message-action-hover sm:h-8"
className={cn(messageActionPillClassName, "h-9 rounded-r-none px-4 sm:h-8")}
{...pointerFocusProps}
disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
>
{isConnecting || isSendBusy ? "Sending..." : "Implement"}
</Button>
</button>
<Menu>
<MenuTrigger
render={
<Button
size="sm"
variant="default"
className="h-9 rounded-l-none rounded-r-full border-l-message-action-foreground/20 bg-message-action px-2 text-message-action-foreground hover:bg-message-action-hover sm:h-8"
<button
type="button"
className={cn(
messageActionPillClassName,
"h-9 rounded-l-none border-l border-message-action-foreground/20 px-2 sm:h-8",
)}
aria-label="Implementation actions"
{...pointerFocusProps}
disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
Expand Down
12 changes: 4 additions & 8 deletions apps/web/src/components/chat/ModelListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,9 @@ export const ModelListRow = memo(function ModelListRow(props: {
value={modelPickerModelKey(props.instanceId, props.model.slug)}
disabled={Boolean(props.disabledReason)}
className={cn(
"group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2 transition-[background-color,box-shadow,color]",
"hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] data-highlighted:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] data-selected:bg-foreground/[0.08] data-selected:text-foreground data-selected:ring-0 [&[data-highlighted][data-selected]]:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))]",
"group relative w-full !min-w-0 max-w-full cursor-pointer",
props.disabledReason &&
"data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent",
"data-disabled:pointer-events-auto data-disabled:cursor-not-allowed",
)}
>
<div className="min-w-0 flex-1 text-left">
Expand Down Expand Up @@ -103,11 +102,8 @@ export const ModelListRow = memo(function ModelListRow(props: {
render={
<Button
size="icon-xs"
variant="ghost"
className={cn(
"-mr-1 shrink-0 text-muted-foreground/70 opacity-64 transition-[color,opacity] hover:text-foreground hover:opacity-100 group-hover:opacity-100",
props.isFavorite && "text-foreground opacity-100",
)}
variant="ghost-muted"
className="-mr-1 shrink-0"
onClick={(event) => {
event.stopPropagation();
props.onToggleFavorite();
Expand Down
Loading
Loading