Skip to content
Merged
1 change: 1 addition & 0 deletions apps/mesh/index.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>deco Studio</title>
<meta name="description" content="Hire specialized agents, give them access to your tools, and let them deliver real projects — with planning, verification, and observability built in." />
<meta property="og:title" content="deco Studio" />
Expand Down
3 changes: 1 addition & 2 deletions apps/mesh/src/web/components/chat/ice-breakers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ function IceBreakersContent({ connectionId }: { connectionId: string | null }) {
}

return (
<div className="relative w-full">
<div className="relative w-full mb-3">
<IceBreakersUI
prompts={prompts}
onSelect={handlePromptSelection}
Expand Down Expand Up @@ -341,7 +341,6 @@ export function IceBreakers({ className }: IceBreakersProps) {

return (
<div
style={{ minHeight: "32px" }}
className={cn(
"flex flex-wrap items-center justify-center gap-2",
className,
Expand Down
24 changes: 14 additions & 10 deletions apps/mesh/src/web/components/chat/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
Users03,
XCircle,
} from "@untitledui/icons";
import { useIsMobile } from "@deco/ui/hooks/use-mobile.ts";
import type { FormEvent } from "react";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import type { Metadata } from "./types.ts";
Expand Down Expand Up @@ -68,6 +69,7 @@ function DecopilotIconButton({
const [open, setOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const { org } = useProjectContext();
const isMobile = useIsMobile();

const decopilot = getWellKnownDecopilotVirtualMCP(org.id);

Expand All @@ -76,15 +78,15 @@ function DecopilotIconButton({
(virtualMcp) => !virtualMcp.id || !isDecopilot(virtualMcp.id),
);

// Focus search input when popover opens
// Focus search input when popover opens (skip on mobile to avoid keyboard popup)
// oxlint-disable-next-line ban-use-effect/ban-use-effect
useEffect(() => {
if (open) {
if (open && !isMobile) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 0);
}
}, [open]);
}, [open, isMobile]);

const handleVirtualMcpChange = (virtualMcpId: string | null) => {
onVirtualMcpChange(virtualMcpId);
Expand Down Expand Up @@ -146,10 +148,11 @@ function DecopilotIconButton({
{!open && <TooltipContent side="top">Decopilot</TooltipContent>}
</Tooltip>
<PopoverContent
className="w-[550px] p-0 overflow-hidden"
className="w-[min(550px,calc(100vw-2rem))] p-0 overflow-hidden"
align="start"
side="top"
sideOffset={8}
collisionPadding={16}
>
<VirtualMCPPopoverContent
virtualMcps={filteredVirtualMcps}
Expand Down Expand Up @@ -183,18 +186,19 @@ function VirtualMCPBadge({
const searchInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const { org } = useProjectContext();
const isMobile = useIsMobile();

const virtualMcp = virtualMcps.find((g) => g.id === virtualMcpId);

// Focus search input when popover opens
// Focus search input when popover opens (skip on mobile to avoid keyboard popup)
// oxlint-disable-next-line ban-use-effect/ban-use-effect
useEffect(() => {
if (open) {
if (open && !isMobile) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 0);
}
}, [open]);
}, [open, isMobile]);

const color = getAgentColor(virtualMcpId);

Expand Down Expand Up @@ -247,7 +251,7 @@ function VirtualMCPBadge({
</button>
</PopoverTrigger>
<PopoverContent
className="w-[550px] p-0 overflow-hidden"
className="w-[min(550px,calc(100vw-2rem))] p-0 overflow-hidden"
align="start"
side="top"
sideOffset={8}
Expand Down Expand Up @@ -496,7 +500,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 shadow-sm",
"w-full relative rounded-xl min-h-[110px] md:min-h-[130px] flex flex-col border border-border bg-background shadow-sm",
)}
>
<div className="group/input relative flex flex-col gap-2 flex-1">
Expand All @@ -509,7 +513,7 @@ export function ChatInput({
selectedModel={model}
/>
{/* Focus hint — hidden when editor is focused */}
<span className="absolute top-3 right-3 text-xs text-muted-foreground/50 pointer-events-none select-none group-focus-within/input:hidden">
<span className="absolute top-3 right-3 text-xs text-muted-foreground/50 pointer-events-none select-none group-focus-within/input:hidden hidden md:inline">
{isMac ? "⌘" : "Ctrl+"}L to focus
</span>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/mesh/src/web/components/chat/message/assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ export function MessageAssistant({
return (
<Container className={className}>
{hasContent ? (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3 sm:gap-2">
{hasReasoning && (
<ThoughtSummary
duration={duration}
Expand Down
4 changes: 2 additions & 2 deletions apps/mesh/src/web/components/chat/message/pair.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,10 @@ export function MessagePair({ pair, isLastPair, status }: MessagePairProps) {
};

return (
<div ref={handlePairRef} className="flex flex-col pb-2">
<div ref={handlePairRef} className="flex flex-col pb-2 sm:pb-2">
{/* Sticky overlay to prevent scrolling content from appearing above the user message */}
<div className="sticky top-0 z-50 w-full h-4 bg-background" />
<div className="sticky mb-6 top-4 z-50">
<div className="sticky mb-8 sm:mb-6 top-4 z-50">
<MessageUser message={pair.user} onScrollToPair={scrollToPair} />
</div>
{/* Single MessageAssistant - handles all states internally */}
Expand Down
147 changes: 91 additions & 56 deletions apps/mesh/src/web/components/chat/select-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import {
DialogTitle,
DialogTrigger,
} from "@deco/ui/components/dialog.tsx";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "@deco/ui/components/drawer.tsx";
import { useIsMobile } from "@deco/ui/hooks/use-mobile.ts";
import {
Select,
SelectContent,
Expand Down Expand Up @@ -850,7 +857,7 @@ function ConnectionModelList({
const grouped = groupByTier(filterModels(browseable));

return (
<div className="flex-1 overflow-y-auto px-0.5 pt-1">
<div className="flex-1 overflow-y-auto px-0.5 pt-1 [touch-action:pan-y]">
{TIER_IDS.map((tierId) => (
<ModelTierSection
key={tierId}
Expand Down Expand Up @@ -915,12 +922,12 @@ function SelectedModelDisplay({
className="w-3.5 h-3.5 shrink-0 rounded-sm"
alt={model.title}
/>
<span className="text-sm truncate whitespace-nowrap hidden md:inline text-muted-foreground">
<span className="text-sm truncate whitespace-nowrap text-muted-foreground max-w-[100px] sm:max-w-none">
{displayName}
</span>
<ChevronDown
size={14}
className="text-muted-foreground opacity-50 shrink-0 hidden md:inline"
className="text-muted-foreground opacity-50 shrink-0"
/>
</div>
);
Expand All @@ -941,8 +948,8 @@ export function modelSupportsFiles(

function ModelSelectorContentFallback() {
return (
<div className="flex flex-col md:flex-row h-[460px]">
<div className="flex-1 flex flex-col md:border-r md:w-[420px] md:min-w-[420px]">
<div className="flex flex-col md:flex-row h-full sm:h-[460px] min-h-0">
<div className="flex-1 flex flex-col md:border-r md:w-[420px] md:min-w-[420px] min-h-0 overflow-hidden">
<div className="border-b border-border h-12 bg-background/95 backdrop-blur sticky top-0 z-10">
<div className="flex items-center gap-2.5 h-12 px-4">
<Skeleton className="size-4 shrink-0" />
Expand Down Expand Up @@ -1044,10 +1051,10 @@ function ModelSelectorInner({
}

return (
<div className="flex flex-col md:flex-row h-[460px]">
<div className="flex-1 flex flex-col md:border-r md:w-[420px] md:min-w-[420px]">
<div className="flex flex-col md:flex-row h-full sm:h-[460px] min-h-0">
<div className="flex-1 flex flex-col md:border-r md:w-[420px] md:min-w-[420px] min-h-0 overflow-hidden">
<div className="border-b border-border h-12 bg-background/95 backdrop-blur sticky top-0 z-10">
<label className="flex items-center gap-2.5 h-12 px-4 cursor-text">
<label className="flex items-center gap-2.5 h-12 px-4 pr-12 md:pr-4 cursor-text">
<SearchMd size={16} className="text-muted-foreground shrink-0" />
<Input
ref={searchInputRef}
Expand All @@ -1066,7 +1073,7 @@ function ModelSelectorInner({
>
<SelectTrigger
size="sm"
className="w-auto h-8 shrink-0 gap-1 px-2 [&>svg:last-child]:hidden"
className="w-auto max-w-[140px] h-8 shrink-0 gap-1.5 px-2 [&>svg:last-child]:hidden"
onClick={(e) => e.stopPropagation()}
>
<SelectValue placeholder="Key">
Expand All @@ -1075,18 +1082,25 @@ function ModelSelectorInner({
const provider = key
? providerMap[key.providerId]
: undefined;
return provider?.logo ? (
<img
src={provider.logo}
alt={provider.name}
className="w-4 h-4 rounded"
/>
) : (
<div className="w-4 h-4 rounded bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
{(provider?.name ?? key?.providerId ?? "?")
.slice(0, 1)
.toUpperCase()}
</div>
return (
<span className="flex items-center gap-1.5 min-w-0">
{provider?.logo ? (
<img
src={provider.logo}
alt={provider.name}
className="w-4 h-4 rounded shrink-0"
/>
) : (
<div className="w-4 h-4 rounded bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary shrink-0">
{(provider?.name ?? key?.providerId ?? "?")
.slice(0, 1)
.toUpperCase()}
</div>
)}
<span className="text-xs truncate">
{provider?.name ?? key?.providerId ?? "Key"}
</span>
</span>
);
})()}
</SelectValue>
Expand Down Expand Up @@ -1225,48 +1239,69 @@ export function ModelSelector({
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const standalone = onModelChange !== undefined;
const isMobile = useIsMobile();

const triggerButton = (
<Button
variant={variant === "borderless" ? "ghost" : "outline"}
size="sm"
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",
className,
)}
>
{standalone ? (
<SelectedModelDisplay
model={modelProp ?? null}
placeholder={placeholder}
isLoading={isLoadingProp}
/>
) : (
<ModelSelectorTriggerContent placeholder={placeholder} />
)}
</Button>
);

const selectorContent = (
<Suspense fallback={<ModelSelectorContentFallback />}>
{standalone ? (
<ModelSelectorInner
onClose={() => setOpen(false)}
credentialId={credentialIdProp ?? null}
onCredentialChange={onCredentialChange ?? (() => {})}
selectedModel={modelProp ?? null}
onModelChange={onModelChange}
/>
) : (
<ModelSelectorContent onClose={() => setOpen(false)} />
)}
</Suspense>
);

if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{triggerButton}</DrawerTrigger>
<DrawerContent className="p-0 flex flex-col max-h-[95vh]">
<DrawerTitle className="sr-only">Select model</DrawerTitle>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{selectorContent}
</div>
</DrawerContent>
</Drawer>
);
}

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant={variant === "borderless" ? "ghost" : "outline"}
size="sm"
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",
className,
)}
>
{standalone ? (
<SelectedModelDisplay
model={modelProp ?? null}
placeholder={placeholder}
isLoading={isLoadingProp}
/>
) : (
<ModelSelectorTriggerContent placeholder={placeholder} />
)}
</Button>
</DialogTrigger>
<DialogTrigger asChild>{triggerButton}</DialogTrigger>
<DialogContent
className="p-0 gap-0 sm:max-w-fit overflow-hidden"
className="p-0 gap-0 sm:max-w-fit overflow-hidden h-[100dvh] sm:h-auto max-h-[100dvh] sm:max-h-[85vh] w-full max-w-full sm:max-w-fit rounded-none sm:rounded-xl border-0 sm:border"
closeButtonClassName="top-3.5 right-3.5 z-20"
>
<DialogTitle className="sr-only">Select model</DialogTitle>
<Suspense fallback={<ModelSelectorContentFallback />}>
{standalone ? (
<ModelSelectorInner
onClose={() => setOpen(false)}
credentialId={credentialIdProp ?? null}
onCredentialChange={onCredentialChange ?? (() => {})}
selectedModel={modelProp ?? null}
onModelChange={onModelChange}
/>
) : (
<ModelSelectorContent onClose={() => setOpen(false)} />
)}
</Suspense>
{selectorContent}
</DialogContent>
</Dialog>
);
Expand Down
Loading
Loading