refactor: Componentize Chat Form Prompt Picker

This commit is contained in:
Aleksander Grygier 2026-01-26 09:36:13 +01:00
parent 176abf3175
commit fa0cad2e6e
8 changed files with 405 additions and 194 deletions

View File

@ -2,11 +2,13 @@
import { mcpClient } from '$lib/clients/mcp.client';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { getFaviconUrl, debounce } from '$lib/utils';
import { debounce } from '$lib/utils';
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
import { fly } from 'svelte/transition';
import { SvelteMap } from 'svelte/reactivity';
import { SearchInput } from '$lib/components/app';
import ChatFormPromptPickerList from './ChatFormPromptPickerList.svelte';
import ChatFormPromptPickerHeader from './ChatFormPromptPickerHeader.svelte';
import ChatFormPromptPickerArgumentForm from './ChatFormPromptPickerArgumentForm.svelte';
interface Props {
class?: string;
@ -237,6 +239,18 @@
}, 150);
}
function handleArgFocus(argName: string) {
if ((suggestions[argName]?.length ?? 0) > 0) {
activeAutocomplete = argName;
}
}
function handleCancelArgumentForm() {
selectedPrompt = null;
promptArgs = {};
promptError = null;
}
export function handleKeydown(event: KeyboardEvent): boolean {
if (!isOpen) return false;
@ -316,202 +330,39 @@
<div class="overflow-hidden rounded-xl border border-border/50 bg-popover shadow-xl">
{#if selectedPrompt}
{@const server = serverSettingsMap.get(selectedPrompt.serverName)}
{@const faviconUrl = server ? getFaviconUrl(server.url) : null}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : selectedPrompt.serverName}
<div class="p-4">
<div class="flex items-start gap-3">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="mt-0.5 h-5 w-5 shrink-0 rounded"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<ChatFormPromptPickerHeader prompt={selectedPrompt} {server} {serverLabel} />
<div class="min-w-0 flex-1">
<div class="text-xs text-muted-foreground">
{server ? mcpStore.getServerLabel(server) : selectedPrompt.serverName}
</div>
<div class="flex items-center gap-2">
<span class="font-medium">
{selectedPrompt.title || selectedPrompt.name}
</span>
{#if selectedPrompt.arguments?.length}
<span class="rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{selectedPrompt.arguments.length} arg{selectedPrompt.arguments.length > 1
? 's'
: ''}
</span>
{/if}
</div>
{#if selectedPrompt.description}
<p class="mt-1 text-sm text-muted-foreground">
{selectedPrompt.description}
</p>
{/if}
</div>
</div>
<form onsubmit={handleArgumentSubmit} class="space-y-3 pt-4">
{#each selectedPrompt.arguments ?? [] as arg (arg.name)}
<div class="relative grid gap-1">
<label
for="arg-{arg.name}"
class="mb-1 flex items-center gap-2 text-sm text-muted-foreground"
>
<span>
{arg.name}
{#if arg.required}<span class="text-destructive">*</span>{/if}
</span>
{#if loadingSuggestions[arg.name]}
<span class="text-xs text-muted-foreground/50">...</span>
{/if}
</label>
<input
id="arg-{arg.name}"
type="text"
value={promptArgs[arg.name] ?? ''}
oninput={(e) => handleArgInput(arg.name, e.currentTarget.value)}
onkeydown={(e) => handleArgKeydown(e, arg.name)}
onblur={() => handleArgBlur(arg.name)}
onfocus={() => {
if ((suggestions[arg.name]?.length ?? 0) > 0) {
activeAutocomplete = arg.name;
}
}}
placeholder={arg.description || arg.name}
required={arg.required}
autocomplete="off"
class="w-full rounded-lg border border-border/50 bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
/>
{#if activeAutocomplete === arg.name && (suggestions[arg.name]?.length ?? 0) > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ y: -5, duration: 100 }}
>
{#each suggestions[arg.name] ?? [] as suggestion, i (suggestion)}
<button
type="button"
onmousedown={() => selectSuggestion(arg.name, suggestion)}
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i ===
autocompleteIndex
? 'bg-accent'
: ''}"
>
{suggestion}
</button>
{/each}
</div>
{/if}
</div>
{/each}
{#if promptError}
<div
class="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
role="alert"
>
<span class="shrink-0"></span>
<span>{promptError}</span>
</div>
{/if}
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => {
selectedPrompt = null;
promptArgs = {};
promptError = null;
}}
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
>
Cancel
</button>
<button
type="submit"
class="rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
>
Use Prompt
</button>
</div>
</form>
<ChatFormPromptPickerArgumentForm
prompt={selectedPrompt}
{promptArgs}
{suggestions}
{loadingSuggestions}
{activeAutocomplete}
{autocompleteIndex}
{promptError}
onArgInput={handleArgInput}
onArgKeydown={handleArgKeydown}
onArgBlur={handleArgBlur}
onArgFocus={handleArgFocus}
onSelectSuggestion={selectSuggestion}
onSubmit={handleArgumentSubmit}
onCancel={handleCancelArgumentForm}
/>
</div>
{:else}
<div>
{#if showSearchInput}
<div class="p-2 pb-0">
<SearchInput placeholder="Search prompts..." bind:value={internalSearchQuery} />
</div>
{/if}
<div class="max-h-64 overflow-y-auto p-2">
{#if isLoading}
<div class="flex items-center justify-center py-6 text-sm text-muted-foreground">
Loading prompts...
</div>
{:else if filteredPrompts.length === 0}
<div class="py-6 text-center text-sm text-muted-foreground">
{prompts.length === 0 ? 'No MCP prompts available' : 'No prompts found'}
</div>
{:else}
{#each filteredPrompts as prompt, index (prompt.serverName + ':' + prompt.name)}
{@const server = serverSettingsMap.get(prompt.serverName)}
{@const faviconUrl = server ? getFaviconUrl(server.url) : null}
<button
type="button"
onclick={() => handlePromptClick(prompt)}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent {index ===
selectedIndex
? 'bg-accent'
: ''}"
>
<div class="min-w-0 flex-1">
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span>{server ? mcpStore.getServerLabel(server) : prompt.serverName}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">{prompt.title || prompt.name}</span>
{#if prompt.arguments && prompt.arguments.length > 0}
<span class="text-xs text-muted-foreground">
({prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''})
</span>
{/if}
</div>
{#if prompt.description}
<p class="truncate text-sm text-muted-foreground">
{prompt.description}
</p>
{/if}
</div>
</button>
{/each}
{/if}
</div>
</div>
<ChatFormPromptPickerList
prompts={filteredPrompts}
{isLoading}
{selectedIndex}
bind:searchQuery={internalSearchQuery}
{showSearchInput}
{serverSettingsMap}
getServerLabel={(server) => mcpStore.getServerLabel(server)}
onPromptClick={handlePromptClick}
/>
{/if}
</div>
</div>

View File

@ -0,0 +1,83 @@
<script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
interface Props {
prompt: MCPPromptInfo;
promptArgs: Record<string, string>;
suggestions: Record<string, string[]>;
loadingSuggestions: Record<string, boolean>;
activeAutocomplete: string | null;
autocompleteIndex: number;
promptError: string | null;
onArgInput: (argName: string, value: string) => void;
onArgKeydown: (event: KeyboardEvent, argName: string) => void;
onArgBlur: (argName: string) => void;
onArgFocus: (argName: string) => void;
onSelectSuggestion: (argName: string, value: string) => void;
onSubmit: (event: SubmitEvent) => void;
onCancel: () => void;
}
let {
prompt,
promptArgs,
suggestions,
loadingSuggestions,
activeAutocomplete,
autocompleteIndex,
promptError,
onArgInput,
onArgKeydown,
onArgBlur,
onArgFocus,
onSelectSuggestion,
onSubmit,
onCancel
}: Props = $props();
</script>
<form onsubmit={onSubmit} class="space-y-3 pt-4">
{#each prompt.arguments ?? [] as arg (arg.name)}
<ChatFormPromptPickerArgumentInput
argument={arg}
value={promptArgs[arg.name] ?? ''}
suggestions={suggestions[arg.name] ?? []}
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
isAutocompleteActive={activeAutocomplete === arg.name}
autocompleteIndex={activeAutocomplete === arg.name ? autocompleteIndex : 0}
onInput={(value) => onArgInput(arg.name, value)}
onKeydown={(e) => onArgKeydown(e, arg.name)}
onBlur={() => onArgBlur(arg.name)}
onFocus={() => onArgFocus(arg.name)}
onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
/>
{/each}
{#if promptError}
<div
class="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
role="alert"
>
<span class="shrink-0"></span>
<span>{promptError}</span>
</div>
{/if}
<div class="flex justify-end gap-2">
<button
type="button"
onclick={onCancel}
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
>
Cancel
</button>
<button
type="submit"
class="rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
>
Use Prompt
</button>
</div>
</form>

View File

@ -0,0 +1,86 @@
<script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import { fly } from 'svelte/transition';
type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number];
interface Props {
argument: PromptArgument;
value: string;
suggestions?: string[];
isLoadingSuggestions?: boolean;
isAutocompleteActive?: boolean;
autocompleteIndex?: number;
onInput: (value: string) => void;
onKeydown: (event: KeyboardEvent) => void;
onBlur: () => void;
onFocus: () => void;
onSelectSuggestion: (value: string) => void;
}
let {
argument,
value = '',
suggestions = [],
isLoadingSuggestions = false,
isAutocompleteActive = false,
autocompleteIndex = 0,
onInput,
onKeydown,
onBlur,
onFocus,
onSelectSuggestion
}: Props = $props();
</script>
<div class="relative grid gap-1">
<label
for="arg-{argument.name}"
class="mb-1 flex items-center gap-2 text-sm text-muted-foreground"
>
<span>
{argument.name}
{#if argument.required}
<span class="text-destructive">*</span>
{/if}
</span>
{#if isLoadingSuggestions}
<span class="text-xs text-muted-foreground/50">...</span>
{/if}
</label>
<input
id="arg-{argument.name}"
type="text"
{value}
oninput={(e) => onInput(e.currentTarget.value)}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={onFocus}
placeholder={argument.description || argument.name}
required={argument.required}
autocomplete="off"
class="w-full rounded-lg border border-border/50 bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
/>
{#if isAutocompleteActive && suggestions.length > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ y: -5, duration: 100 }}
>
{#each suggestions as suggestion, i (suggestion)}
<button
type="button"
onmousedown={() => onSelectSuggestion(suggestion)}
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
? 'bg-accent'
: ''}"
>
{suggestion}
</button>
{/each}
</div>
{/if}
</div>

View File

@ -0,0 +1,50 @@
<script lang="ts">
import type { MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
import { getFaviconUrl } from '$lib/utils';
interface Props {
prompt: MCPPromptInfo;
server: MCPServerSettingsEntry | undefined;
serverLabel: string;
}
let { prompt, server, serverLabel }: Props = $props();
let faviconUrl = $derived(server ? getFaviconUrl(server.url) : null);
</script>
<div class="flex items-start gap-3">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="mt-0.5 h-5 w-5 shrink-0 rounded"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<div class="min-w-0 flex-1">
<div class="text-xs text-muted-foreground">
{serverLabel}
</div>
<div class="flex items-center gap-2">
<span class="font-medium">
{prompt.title || prompt.name}
</span>
{#if prompt.arguments?.length}
<span class="rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
</span>
{/if}
</div>
{#if prompt.description}
<p class="mt-1 text-sm text-muted-foreground">
{prompt.description}
</p>
{/if}
</div>
</div>

View File

@ -0,0 +1,60 @@
<script lang="ts">
import type { MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
import { SearchInput } from '$lib/components/app';
import ChatFormPromptPickerListItem from './ChatFormPromptPickerListItem.svelte';
import ChatFormPromptPickerListItemSkeleton from './ChatFormPromptPickerListItemSkeleton.svelte';
import { SvelteMap } from 'svelte/reactivity';
interface Props {
prompts: MCPPromptInfo[];
isLoading: boolean;
selectedIndex: number;
searchQuery: string;
showSearchInput: boolean;
serverSettingsMap: SvelteMap<string, MCPServerSettingsEntry>;
getServerLabel: (server: MCPServerSettingsEntry) => string;
onPromptClick: (prompt: MCPPromptInfo) => void;
}
let {
prompts,
isLoading,
selectedIndex,
searchQuery = $bindable(),
showSearchInput,
serverSettingsMap,
getServerLabel,
onPromptClick
}: Props = $props();
</script>
<div>
{#if showSearchInput}
<div class="p-2 pb-0">
<SearchInput placeholder="Search prompts..." bind:value={searchQuery} />
</div>
{/if}
<div class="max-h-64 overflow-y-auto p-2">
{#if isLoading}
<ChatFormPromptPickerListItemSkeleton />
{:else if prompts.length === 0}
<div class="py-6 text-center text-sm text-muted-foreground">
{prompts.length === 0 ? 'No MCP prompts available' : 'No prompts found'}
</div>
{:else}
{#each prompts as prompt, index (prompt.serverName + ':' + prompt.name)}
{@const server = serverSettingsMap.get(prompt.serverName)}
{@const serverLabel = server ? getServerLabel(server) : prompt.serverName}
<ChatFormPromptPickerListItem
{prompt}
{server}
{serverLabel}
isSelected={index === selectedIndex}
onClick={() => onPromptClick(prompt)}
/>
{/each}
{/if}
</div>
</div>

View File

@ -0,0 +1,57 @@
<script lang="ts">
import type { MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
import { getFaviconUrl } from '$lib/utils';
interface Props {
prompt: MCPPromptInfo;
server: MCPServerSettingsEntry | undefined;
serverLabel: string;
isSelected?: boolean;
onClick: () => void;
}
let { prompt, server, serverLabel, isSelected = false, onClick }: Props = $props();
let faviconUrl = $derived(server ? getFaviconUrl(server.url) : null);
</script>
<button
type="button"
onclick={onClick}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent {isSelected
? 'bg-accent'
: ''}"
>
<div class="min-w-0 flex-1">
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span>{serverLabel}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">{prompt.title || prompt.name}</span>
{#if prompt.arguments && prompt.arguments.length > 0}
<span class="text-xs text-muted-foreground">
({prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''})
</span>
{/if}
</div>
{#if prompt.description}
<p class="truncate text-sm text-muted-foreground">
{prompt.description}
</p>
{/if}
</div>
</button>

View File

@ -0,0 +1,18 @@
<div class="flex w-full items-start gap-3 rounded-lg px-3 py-2">
<div class="min-w-0 flex-1 space-y-2">
<!-- Server label skeleton -->
<div class="mb-2 flex items-center gap-1.5">
<div class="h-3 w-3 shrink-0 animate-pulse rounded-sm bg-muted"></div>
<div class="h-3 w-24 animate-pulse rounded bg-muted"></div>
</div>
<!-- Title skeleton -->
<div class="flex items-center gap-2">
<div class="h-4 w-32 animate-pulse rounded bg-muted"></div>
<div class="h-4 w-12 animate-pulse rounded-full bg-muted"></div>
</div>
<!-- Description skeleton -->
<div class="h-3 w-full animate-pulse rounded bg-muted"></div>
</div>
</div>

View File

@ -14,7 +14,13 @@ export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/Chat
export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
export { default as ChatFormPromptPicker } from './chat/ChatForm/ChatFormPromptPicker.svelte';
export { default as ChatFormPromptPicker } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte';
export { default as ChatFormPromptPickerArgumentForm } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentForm.svelte';
export { default as ChatFormPromptPickerArgumentInput } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentInput.svelte';
export { default as ChatFormPromptPickerHeader } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerHeader.svelte';
export { default as ChatFormPromptPickerList } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerList.svelte';
export { default as ChatFormPromptPickerListItem } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerListItem.svelte';
export { default as ChatFormPromptPickerListItemSkeleton } from './chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPickerListItemSkeleton.svelte';
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';