Skip to content
Open
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
179 changes: 130 additions & 49 deletions apps/mesh/src/web/components/chat/input.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { IntegrationIcon } from "@/web/components/integration-icon.tsx";
import { calculateUsageStats } from "@/web/lib/usage-utils.ts";
import { getAgentColor } from "@/web/utils/agent-color";
import { Button } from "@deco/ui/components/button.tsx";
Expand Down Expand Up @@ -95,15 +94,51 @@ function DecopilotIconButton({
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
<button
type="button"
variant="ghost"
size="icon"
className="size-8 rounded-full"
className={cn(
"relative flex items-center justify-center size-8 rounded-md text-muted-foreground/75 transition-colors shrink-0",
disabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:text-muted-foreground",
)}
disabled={disabled}
>
<svg className="absolute inset-0 size-full" fill="none">
<defs>
<linearGradient
id="agent-border-gradient-decopilot"
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2="32"
y2="32"
>
<animateTransform
attributeName="gradientTransform"
type="rotate"
from="0 16 16"
to="360 16 16"
dur="6s"
repeatCount="indefinite"
/>
<stop offset="0%" stopColor="var(--chart-1)" />
<stop offset="100%" stopColor="var(--chart-4)" />
</linearGradient>
</defs>
<rect
x="0.5"
y="0.5"
width="31"
height="31"
rx="5.5"
stroke="url(#agent-border-gradient-decopilot)"
strokeWidth="1"
strokeDasharray="3 3"
/>
</svg>
<Users03 size={16} />
</Button>
</button>
</PopoverTrigger>
</TooltipTrigger>
{!open && <TooltipContent side="top">Decopilot</TooltipContent>}
Expand Down Expand Up @@ -198,17 +233,10 @@ function VirtualMCPBadge({
type="button"
disabled={disabled}
className={cn(
"flex items-center gap-1.5 hover:opacity-80 transition-opacity",
"flex items-center gap-1.5 px-2 py-1 rounded-md hover:opacity-80 transition-opacity",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
)}
>
<IntegrationIcon
icon={virtualMcp.icon}
name={virtualMcp.title}
size="2xs"
className="bg-background rounded-sm"
fallbackIcon={virtualMcp.fallbackIcon ?? <Users03 size={10} />}
/>
<span className="text-xs text-white font-normal">
{virtualMcp.title}
</span>
Expand Down Expand Up @@ -335,35 +363,90 @@ export function ChatInput() {
}
};

// Track whether a non-Decopilot agent is active
const hasAgentBadge =
!!selectedVirtualMcp?.id && !isDecopilot(selectedVirtualMcp.id);

// Track if wrapper visuals should still show (stays true during exit animation)
const [showWrapper, setShowWrapper] = useState(false);
if (hasAgentBadge && !showWrapper) {
setShowWrapper(true);
}
Comment on lines +372 to +374
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 27, 2026

Choose a reason for hiding this comment

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

P2: Avoid calling setShowWrapper during render. Move this state update into a useEffect so React doesn’t warn about state updates in render and risk render loops.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/web/components/chat/input.tsx, line 372:

<comment>Avoid calling setShowWrapper during render. Move this state update into a useEffect so React doesn’t warn about state updates in render and risk render loops.</comment>

<file context>
@@ -335,35 +363,90 @@ export function ChatInput() {
+
+  // Track if wrapper visuals should still show (stays true during exit animation)
+  const [showWrapper, setShowWrapper] = useState(false);
+  if (hasAgentBadge && !showWrapper) {
+    setShowWrapper(true);
+  }
</file context>
Suggested change
if (hasAgentBadge && !showWrapper) {
setShowWrapper(true);
}
useEffect(() => {
if (hasAgentBadge) {
setShowWrapper(true);
}
}, [hasAgentBadge]);
Fix with Cubic


const handleGridTransitionEnd = () => {
if (!hasAgentBadge) {
setShowWrapper(false);
lastAgentRef.current = null;
}
};

// Keep last active agent + color for exit animation
const lastAgentRef = useRef<{
id: string;
virtualMcps: VirtualMCPInfo[];
color: ReturnType<typeof getAgentColor>;
} | null>(null);

const color = selectedVirtualMcp
? getAgentColor(selectedVirtualMcp.id)
: null;

if (hasAgentBadge && selectedVirtualMcp?.id) {
lastAgentRef.current = { id: selectedVirtualMcp.id, virtualMcps, color };
}

const badgeAgent = hasAgentBadge ? selectedVirtualMcp : null;
const badgeAgentId = badgeAgent?.id ?? lastAgentRef.current?.id;
const badgeVirtualMcps = badgeAgent
? virtualMcps
: (lastAgentRef.current?.virtualMcps ?? []);
// Use current color when active, last color during exit animation
const wrapperBg = color?.bg ?? lastAgentRef.current?.color?.bg;

return (
<div className="flex flex-col w-full min-h-42 justify-end">
<div className="flex flex-col w-full justify-end">
{/* Virtual MCP wrapper with badge */}
<div
className={cn(
"relative rounded-xl w-full flex flex-col",
selectedVirtualMcp && "shadow-sm",
color?.bg,
<div className="relative rounded-xl w-full flex flex-col">
{/* Colored background overlay - stays during exit animation */}
{showWrapper && (
<div
className={cn(
"absolute inset-0 rounded-xl pointer-events-none",
wrapperBg,
)}
/>
)}
>

{/* Highlight floats above the form area */}
<ChatHighlight />

{/* Virtual MCP Badge Header */}
{selectedVirtualMcp?.id && !isDecopilot(selectedVirtualMcp.id) && (
<VirtualMCPBadge
onVirtualMcpChange={setVirtualMcpId}
virtualMcpId={selectedVirtualMcp.id}
virtualMcps={virtualMcps}
disabled={isStreaming}
/>
)}
{/* Virtual MCP Badge Header - animated expand/collapse */}
<div
className={cn(
"relative z-10 grid transition-[grid-template-rows] duration-250 ease-out overflow-hidden rounded-t-xl",
hasAgentBadge ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
onTransitionEnd={handleGridTransitionEnd}
>
<div className="overflow-hidden">
{badgeAgentId && (
<VirtualMCPBadge
onVirtualMcpChange={setVirtualMcpId}
virtualMcpId={badgeAgentId}
virtualMcps={badgeVirtualMcps}
disabled={isStreaming}
/>
)}
</div>
</div>

{/* Inner container with the input */}
<div className="p-0.5">
<div
className={cn(
"transition-[padding] duration-250 ease-out",
showWrapper ? "p-0.5" : "p-0",
)}
>
<TiptapProvider
key={activeThreadId}
tiptapDoc={tiptapDoc}
Expand All @@ -375,8 +458,7 @@ export function ChatInput() {
<form
onSubmit={handleSubmit}
className={cn(
"w-full relative rounded-xl min-h-[130px] flex flex-col border border-border bg-background",
!selectedVirtualMcp && "shadow-sm",
"w-full relative rounded-xl min-h-[130px] flex flex-col border border-border bg-background shadow-sm",
)}
>
<div className="relative flex flex-col gap-2 flex-1">
Expand All @@ -391,14 +473,13 @@ export function ChatInput() {

{/* Bottom Actions Row */}
<div className="flex items-center justify-between p-2.5">
{/* Left Actions (agent selector and usage stats) */}
<div className="flex items-center gap-2 min-w-0 overflow-hidden">
{/* Left Actions (agent, file upload, mode) */}
<div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
{isRunInProgress && (
<span className="text-xs text-muted-foreground shrink-0">
Run in progress
</span>
)}
{/* Always show selector button - DecopilotIconButton for Decopilot, VirtualMCPSelector for others */}
{selectedVirtualMcp && isDecopilot(selectedVirtualMcp.id) ? (
<DecopilotIconButton
onVirtualMcpChange={setVirtualMcpId}
Expand All @@ -414,6 +495,17 @@ export function ChatInput() {
disabled={isStreaming}
/>
)}
<FileUploadButton
selectedModel={selectedModel}
isStreaming={isStreaming}
/>
<ModeSelector
selectedMode={selectedMode}
onModeChange={setSelectedMode}
placeholder="Mode"
variant="borderless"
disabled={isStreaming}
/>
<ThreadUsageStats usage={usage} />
{contextWindow && lastTotalTokens > 0 && (
<ContextWindow
Expand All @@ -423,26 +515,15 @@ export function ChatInput() {
)}
</div>

{/* Right Actions (model, mode, file upload, send button) */}
<div className="flex items-center gap-1">
{/* Right Actions (model, send) */}
<div className="flex items-center gap-1.5">
<ModelSelector
selectedModel={selectedModel ?? undefined}
onModelChange={setSelectedModel}
modelsConnections={modelsConnections}
placeholder="Model"
variant="borderless"
/>
<ModeSelector
selectedMode={selectedMode}
onModeChange={setSelectedMode}
placeholder="Mode"
variant="borderless"
disabled={isStreaming}
/>
<FileUploadButton
selectedModel={selectedModel}
isStreaming={isStreaming}
/>

<Button
type={showStopOrCancel ? "button" : "submit"}
Expand Down
78 changes: 16 additions & 62 deletions apps/mesh/src/web/components/chat/select-mode.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Badge } from "@deco/ui/components/badge.tsx";
import { Button } from "@deco/ui/components/button.tsx";
import {
Popover,
PopoverContent,
Expand All @@ -13,13 +12,7 @@ import {
} from "@deco/ui/components/tooltip.tsx";
import { cn } from "@deco/ui/lib/utils.ts";
import type { ToolSelectionStrategy } from "@/mcp-clients/virtual-mcp/types";
import {
ArrowsRight,
Check,
ChevronDown,
Code01,
Lightbulb02,
} from "@untitledui/icons";
import { ArrowsRight, Check, Code01, Lightbulb02 } from "@untitledui/icons";
import { useState } from "react";

/**
Expand Down Expand Up @@ -69,8 +62,8 @@ function ModeItemContent({
<div
onClick={onSelect}
className={cn(
"flex items-start gap-3 py-3 px-3 hover:bg-accent cursor-pointer rounded-lg transition-colors",
isSelected && "bg-accent",
"flex items-start gap-3 py-3 px-3 hover:bg-accent/50 cursor-pointer rounded-lg transition-colors",
isSelected && "bg-accent/50",
)}
>
{/* Icon */}
Expand Down Expand Up @@ -101,42 +94,6 @@ function ModeItemContent({
);
}

function SelectedModeDisplay({
mode,
placeholder = "Select mode",
}: {
mode: ToolSelectionStrategy | undefined;
placeholder?: string;
}) {
if (!mode) {
return (
<div className="flex items-center gap-1.5">
<span className="text-sm text-muted-foreground">{placeholder}</span>
<ChevronDown
size={14}
className="text-muted-foreground opacity-50 shrink-0"
/>
</div>
);
}

const config = MODE_CONFIGS[mode];
const Icon = config.icon;

return (
<div className="flex items-center gap-0 group-hover:gap-2 group-data-[state=open]:gap-2 min-w-0 overflow-hidden transition-all duration-200">
<Icon className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm text-muted-foreground group-hover:text-foreground group-data-[state=open]:text-foreground truncate whitespace-nowrap max-w-0 opacity-0 group-hover:max-w-[150px] group-hover:opacity-100 group-data-[state=open]:max-w-[150px] group-data-[state=open]:opacity-100 transition-all duration-200 ease-in-out overflow-hidden">
{config.label}
</span>
<ChevronDown
size={14}
className="text-muted-foreground opacity-0 max-w-0 group-hover:opacity-50 group-hover:max-w-[14px] group-data-[state=open]:opacity-50 group-data-[state=open]:max-w-[14px] shrink-0 transition-all duration-200 ease-in-out overflow-hidden"
/>
</div>
);
}

export interface ModeSelectorProps {
selectedMode: ToolSelectionStrategy;
onModeChange: (mode: ToolSelectionStrategy) => void;
Expand All @@ -153,9 +110,7 @@ export interface ModeSelectorProps {
export function ModeSelector({
selectedMode,
onModeChange,
variant = "borderless",
className,
placeholder = "Mode",
disabled = false,
}: ModeSelectorProps) {
const [open, setOpen] = useState(false);
Expand All @@ -171,27 +126,26 @@ export function ModeSelector({
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant={variant === "borderless" ? "ghost" : "outline"}
size="sm"
<button
type="button"
disabled={disabled}
className={cn(
"text-sm hover:bg-accent rounded-lg py-0.5 px-1 gap-1 shadow-none cursor-pointer border-0 group focus-visible:ring-0 focus-visible:ring-offset-0 min-w-0 shrink justify-start overflow-hidden",
variant === "borderless" && "md:border-none",
disabled && "cursor-not-allowed opacity-50",
"flex items-center justify-center size-8 rounded-md border border-border text-muted-foreground/75 transition-colors shrink-0",
disabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:text-muted-foreground",
className,
)}
disabled={disabled}
data-state={open ? "open" : "closed"}
>
<SelectedModeDisplay
mode={selectedMode}
placeholder={placeholder}
/>
</Button>
{(() => {
const Icon = MODE_CONFIGS[selectedMode]?.icon ?? ArrowsRight;
return <Icon size={16} />;
})()}
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{MODE_CONFIGS[selectedMode]?.description ?? "Choose agent mode"}
{MODE_CONFIGS[selectedMode]?.label ?? "Choose agent mode"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
Expand Down
Loading