Repository: ggml-org/llama.cpp
Pull request: Webui: Disable attachment button and model selector button when prompt textbox is disabled.

### Description

Prevent the user from interacting with either the attachment button or the model selector button when the chat text input is disabled.

### Changes

Pass the disabled state to the file attachment's DropdownMenu Trigger and to the ModelsSelector.

| Before| After|
| ----------| -------|
| <img width="1942" height="1132" alt="Screenshot-file-attach-enabled" src="https://github.com/user-attachments/assets/3f5f93ae-16b7-400c-9884-fe4d850bb6b6" /> | <img width="1942" height="1132" alt="Screenshot-file-attach-disabled" src="https://github.com/user-attachments/assets/9053d54c-dfc9-44db-86e9-786536545c99" /> |


Changed files:
filename: tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte
patch: @@ -35,7 +35,7 @@
 
 <div class="flex items-center gap-1 {className}">
 	<DropdownMenu.Root>
-		<DropdownMenu.Trigger name="Attach files">
+		<DropdownMenu.Trigger name="Attach files" {disabled}>
 			<Tooltip.Root>
 				<Tooltip.Trigger>
 					<Button

filename: tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
patch: @@ -173,6 +173,7 @@
 	/>
 
 	<ModelsSelector
+		{disabled}
 		bind:this={selectorModelRef}
 		currentModel={conversationModel}
 		forceForegroundText={true}

filename: tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte
patch: @@ -179,51 +179,37 @@
 		});
 	});
 
+	// Handle changes to the model selector pop-down or the model dialog, depending on if the server is in
+	// router mode or not.
 	function handleOpenChange(open: boolean) {
 		if (loading || updating) return;
 
-		if (open) {
-			isOpen = true;
-			searchTerm = '';
-			highlightedIndex = -1;
-
-			// Focus search input after popover opens
-			tick().then(() => {
-				requestAnimationFrame(() => searchInputRef?.focus());
-			});
+		if (isRouter) {
+			if (open) {
+				isOpen = true;
+				searchTerm = '';
+				highlightedIndex = -1;
+
+				// Focus search input after popover opens
+				tick().then(() => {
+					requestAnimationFrame(() => searchInputRef?.focus());
+				});
 
-			if (isRouter) {
 				modelsStore.fetchRouterModels().then(() => {
 					modelsStore.fetchModalitiesForLoadedModels();
 				});
+			} else {
+				isOpen = false;
+				searchTerm = '';
+				highlightedIndex = -1;
 			}
 		} else {
-			isOpen = false;
-			searchTerm = '';
-			highlightedIndex = -1;
-		}
-	}
-
-	function handleTriggerClick() {
-		if (loading || updating) return;
-
-		if (!isRouter) {
-			// Single model mode: show dialog instead of popover
-			showModelDialog = true;
+			showModelDialog = open;
 		}
-		// For router mode, the Popover handles open/close
 	}
 
 	export function open() {
-		if (isRouter) {
-			handleOpenChange(true);
-		} else {
-			showModelDialog = true;
-		}
-	}
-
-	function closeMenu() {
-		handleOpenChange(false);
+		handleOpenChange(true);
 	}
 
 	function handleSearchKeyDown(event: KeyboardEvent) {
@@ -292,7 +278,7 @@
 		}
 
 		if (shouldCloseMenu) {
-			closeMenu();
+			handleOpenChange(false);
 
 			// Focus the chat textarea after model selection
 			requestAnimationFrame(() => {
@@ -360,8 +346,181 @@
 	{:else}
 		{@const selectedOption = getDisplayOption()}
 
-		<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
-			<Popover.Trigger
+		{#if isRouter}
+			<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
+				<Popover.Trigger
+					class={cn(
+						`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
+						!isCurrentModelInCache()
+							? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
+							: forceForegroundText
+								? 'text-foreground'
+								: isHighlightedCurrentModelActive
+									? 'text-foreground'
+									: 'text-muted-foreground',
+						isOpen ? 'text-foreground' : ''
+					)}
+					style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
+					disabled={disabled || updating}
+				>
+					<Package class="h-3.5 w-3.5" />
+
+					<span class="truncate font-medium">
+						{selectedOption?.model || 'Select model'}
+					</span>
+
+					{#if updating}
+						<Loader2 class="h-3 w-3.5 animate-spin" />
+					{:else}
+						<ChevronDown class="h-3 w-3.5" />
+					{/if}
+				</Popover.Trigger>
+
+				<Popover.Content
+					class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
+					align="end"
+					sideOffset={8}
+					collisionPadding={16}
+				>
+					<div class="flex max-h-[50dvh] flex-col overflow-hidden">
+						<div
+							class="order-1 shrink-0 border-b p-4 group-data-[side=top]/popover-content:order-2 group-data-[side=top]/popover-content:border-t group-data-[side=top]/popover-content:border-b-0"
+						>
+							<SearchInput
+								id="model-search"
+								placeholder="Search models..."
+								bind:value={searchTerm}
+								bind:ref={searchInputRef}
+								onClose={() => handleOpenChange(false)}
+								onKeyDown={handleSearchKeyDown}
+							/>
+						</div>
+						<div
+							class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1"
+						>
+							{#if !isCurrentModelInCache() && currentModel}
+								<!-- Show unavailable model as first option (disabled) -->
+								<button
+									type="button"
+									class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
+									role="option"
+									aria-selected="true"
+									aria-disabled="true"
+									disabled
+								>
+									<span class="truncate">{selectedOption?.name || currentModel}</span>
+									<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
+								</button>
+								<div class="my-1 h-px bg-border"></div>
+							{/if}
+							{#if filteredOptions.length === 0}
+								<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
+							{/if}
+							{#each filteredOptions as option, index (option.id)}
+								{@const status = getModelStatus(option.model)}
+								{@const isLoaded = status === ServerModelStatus.LOADED}
+								{@const isLoading = status === ServerModelStatus.LOADING}
+								{@const isSelected = currentModel === option.model || activeId === option.id}
+								{@const isCompatible = isModelCompatible(option)}
+								{@const isHighlighted = index === highlightedIndex}
+								{@const missingModalities = getMissingModalities(option)}
+
+								<div
+									class={cn(
+										'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
+										isCompatible
+											? 'cursor-pointer hover:bg-muted focus:bg-muted'
+											: 'cursor-not-allowed opacity-50',
+										isSelected || isHighlighted
+											? 'bg-accent text-accent-foreground'
+											: isCompatible
+												? 'hover:bg-accent hover:text-accent-foreground'
+												: '',
+										isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
+									)}
+									role="option"
+									aria-selected={isSelected || isHighlighted}
+									aria-disabled={!isCompatible}
+									tabindex={isCompatible ? 0 : -1}
+									onclick={() => isCompatible && handleSelect(option.id)}
+									onmouseenter={() => (highlightedIndex = index)}
+									onkeydown={(e) => {
+										if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
+											e.preventDefault();
+											handleSelect(option.id);
+										}
+									}}
+								>
+									<span class="min-w-0 flex-1 truncate">{option.model}</span>
+
+									{#if missingModalities}
+										<span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
+											{#if missingModalities.vision}
+												<Tooltip.Root>
+													<Tooltip.Trigger>
+														<EyeOff class="h-3.5 w-3.5" />
+													</Tooltip.Trigger>
+													<Tooltip.Content class="z-[9999]">
+														<p>No vision support</p>
+													</Tooltip.Content>
+												</Tooltip.Root>
+											{/if}
+											{#if missingModalities.audio}
+												<Tooltip.Root>
+													<Tooltip.Trigger>
+														<MicOff class="h-3.5 w-3.5" />
+													</Tooltip.Trigger>
+													<Tooltip.Content class="z-[9999]">
+														<p>No audio support</p>
+													</Tooltip.Content>
+												</Tooltip.Root>
+											{/if}
+										</span>
+									{/if}
+
+									{#if isLoading}
+										<Tooltip.Root>
+											<Tooltip.Trigger>
+												<Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
+											</Tooltip.Trigger>
+											<Tooltip.Content class="z-[9999]">
+												<p>Loading model...</p>
+											</Tooltip.Content>
+										</Tooltip.Root>
+									{:else if isLoaded}
+										<Tooltip.Root>
+											<Tooltip.Trigger>
+												<button
+													type="button"
+													class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
+													onclick={(e) => {
+														e.stopPropagation();
+														modelsStore.unloadModel(option.model);
+													}}
+												>
+													<span
+														class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
+													></span>
+													<Power
+														class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
+													/>
+												</button>
+											</Tooltip.Trigger>
+											<Tooltip.Content class="z-[9999]">
+												<p>Unload model</p>
+											</Tooltip.Content>
+										</Tooltip.Root>
+									{:else}
+										<span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
+									{/if}
+								</div>
+							{/each}
+						</div>
+					</div>
+				</Popover.Content>
+			</Popover.Root>
+		{:else}
+			<button
 				class={cn(
 					`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
 					!isCurrentModelInCache()
@@ -374,165 +533,20 @@
 					isOpen ? 'text-foreground' : ''
 				)}
 				style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
-				onclick={handleTriggerClick}
-				disabled={disabled || updating || !isRouter}
+				onclick={() => handleOpenChange(true)}
+				disabled={disabled || updating}
 			>
 				<Package class="h-3.5 w-3.5" />
 
 				<span class="truncate font-medium">
-					{selectedOption?.model || 'Select model'}
+					{selectedOption?.model}
 				</span>
 
 				{#if updating}
 					<Loader2 class="h-3 w-3.5 animate-spin" />
-				{:else if isRouter}
-					<ChevronDown class="h-3 w-3.5" />
 				{/if}
-			</Popover.Trigger>
-
-			<Popover.Content
-				class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
-				align="end"
-				sideOffset={8}
-				collisionPadding={16}
-			>
-				<div class="flex max-h-[50dvh] flex-col overflow-hidden">
-					<div
-						class="order-1 shrink-0 border-b p-4 group-data-[side=top]/popover-content:order-2 group-data-[side=top]/popover-content:border-t group-data-[side=top]/popover-content:border-b-0"
-					>
-						<SearchInput
-							id="model-search"
-							placeholder="Search models..."
-							bind:value={searchTerm}
-							bind:ref={searchInputRef}
-							onClose={closeMenu}
-							onKeyDown={handleSearchKeyDown}
-						/>
-					</div>
-					<div
-						class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1"
-					>
-						{#if !isCurrentModelInCache() && currentModel}
-							<!-- Show unavailable model as first option (disabled) -->
-							<button
-								type="button"
-								class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
-								role="option"
-								aria-selected="true"
-								aria-disabled="true"
-								disabled
-							>
-								<span class="truncate">{selectedOption?.name || currentModel}</span>
-								<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
-							</button>
-							<div class="my-1 h-px bg-border"></div>
-						{/if}
-						{#if filteredOptions.length === 0}
-							<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
-						{/if}
-						{#each filteredOptions as option, index (option.id)}
-							{@const status = getModelStatus(option.model)}
-							{@const isLoaded = status === ServerModelStatus.LOADED}
-							{@const isLoading = status === ServerModelStatus.LOADING}
-							{@const isSelected = currentModel === option.model || activeId === option.id}
-							{@const isCompatible = isModelCompatible(option)}
-							{@const isHighlighted = index === highlightedIndex}
-							{@const missingModalities = getMissingModalities(option)}
-
-							<div
-								class={cn(
-									'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
-									isCompatible
-										? 'cursor-pointer hover:bg-muted focus:bg-muted'
-										: 'cursor-not-allowed opacity-50',
-									isSelected || isHighlighted
-										? 'bg-accent text-accent-foreground'
-										: isCompatible
-											? 'hover:bg-accent hover:text-accent-foreground'
-											: '',
-									isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
-								)}
-								role="option"
-								aria-selected={isSelected || isHighlighted}
-								aria-disabled={!isCompatible}
-								tabindex={isCompatible ? 0 : -1}
-								onclick={() => isCompatible && handleSelect(option.id)}
-								onmouseenter={() => (highlightedIndex = index)}
-								onkeydown={(e) => {
-									if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
-										e.preventDefault();
-										handleSelect(option.id);
-									}
-								}}
-							>
-								<span class="min-w-0 flex-1 truncate">{option.model}</span>
-
-								{#if missingModalities}
-									<span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
-										{#if missingModalities.vision}
-											<Tooltip.Root>
-												<Tooltip.Trigger>
-													<EyeOff class="h-3.5 w-3.5" />
-												</Tooltip.Trigger>
-												<Tooltip.Content class="z-[9999]">
-													<p>No vision support</p>
-												</Tooltip.Content>
-											</Tooltip.Root>
-										{/if}
-										{#if missingModalities.audio}
-											<Tooltip.Root>
-												<Tooltip.Trigger>
-													<MicOff class="h-3.5 w-3.5" />
-												</Tooltip.Trigger>
-												<Tooltip.Content class="z-[9999]">
-													<p>No audio support</p>
-												</Tooltip.Content>
-											</Tooltip.Root>
-										{/if}
-									</span>
-								{/if}
-
-								{#if isLoading}
-									<Tooltip.Root>
-										<Tooltip.Trigger>
-											<Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
-										</Tooltip.Trigger>
-										<Tooltip.Content class="z-[9999]">
-											<p>Loading model...</p>
-										</Tooltip.Content>
-									</Tooltip.Root>
-								{:else if isLoaded}
-									<Tooltip.Root>
-										<Tooltip.Trigger>
-											<button
-												type="button"
-												class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
-												onclick={(e) => {
-													e.stopPropagation();
-													modelsStore.unloadModel(option.model);
-												}}
-											>
-												<span
-													class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
-												></span>
-												<Power
-													class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
-												/>
-											</button>
-										</Tooltip.Trigger>
-										<Tooltip.Content class="z-[9999]">
-											<p>Unload model</p>
-										</Tooltip.Content>
-									</Tooltip.Root>
-								{:else}
-									<span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
-								{/if}
-							</div>
-						{/each}
-					</div>
-				</div>
-			</Popover.Content>
-		</Popover.Root>
+			</button>
+		{/if}
 	{/if}
 </div>