refactor: KeyValuePairs component

This commit is contained in:
Aleksander Grygier 2026-01-12 15:25:43 +01:00
parent 392a6dce0d
commit 08c1acd1db
6 changed files with 138 additions and 83 deletions

View File

@ -27,6 +27,7 @@ export { default as CollapsibleContentBlock } from './chat/ChatMessages/Collapsi
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
export { default as KeyValuePairs } from './misc/KeyValuePairs.svelte';
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';

View File

@ -1,8 +1,8 @@
<script lang="ts">
import { Plus, X } from '@lucide/svelte';
import { Input } from '$lib/components/ui/input';
import { autoResizeTextarea } from '$lib/utils';
import { type HeaderPair, parseHeadersToArray, serializeHeaders } from '$lib/utils/mcp';
import { KeyValuePairs } from '$lib/components/app';
import type { KeyValuePair } from '$lib/types';
import { parseHeadersToArray, serializeHeaders } from '$lib/utils/mcp';
interface Props {
url: string;
@ -23,33 +23,13 @@
}: Props = $props();
// Local state for header pairs
let headerPairs = $state<HeaderPair[]>(parseHeadersToArray(headers));
let headerPairs = $state<KeyValuePair[]>(parseHeadersToArray(headers));
// Sync header pairs to parent when they change
function updateHeaderPairs(newPairs: HeaderPair[]) {
function updateHeaderPairs(newPairs: KeyValuePair[]) {
headerPairs = newPairs;
onHeadersChange(serializeHeaders(newPairs));
}
function addHeaderPair() {
updateHeaderPairs([...headerPairs, { key: '', value: '' }]);
}
function removeHeaderPair(index: number) {
updateHeaderPairs(headerPairs.filter((_, i) => i !== index));
}
function updatePairKey(index: number, key: string) {
const newPairs = [...headerPairs];
newPairs[index] = { ...newPairs[index], key };
updateHeaderPairs(newPairs);
}
function updatePairValue(index: number, value: string) {
const newPairs = [...headerPairs];
newPairs[index] = { ...newPairs[index], value };
updateHeaderPairs(newPairs);
}
</script>
<div class="space-y-3">
@ -70,55 +50,14 @@
{/if}
</div>
<div>
<div class="mb-1 flex items-center justify-between">
<span class="text-xs font-medium">
Custom Headers <span class="text-muted-foreground">(optional)</span>
</span>
<button
type="button"
class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
onclick={addHeaderPair}
>
<Plus class="h-3 w-3" />
Add
</button>
</div>
{#if headerPairs.length > 0}
<div class="space-y-2">
{#each headerPairs as pair, index (index)}
<div class="flex items-start gap-2">
<Input
type="text"
placeholder="Header name"
value={pair.key}
oninput={(e) => updatePairKey(index, e.currentTarget.value)}
class="flex-1"
/>
<textarea
placeholder="Value"
value={pair.value}
oninput={(e) => {
updatePairValue(index, e.currentTarget.value);
autoResizeTextarea(e.currentTarget);
}}
class="flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm leading-5 placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
rows="1"
></textarea>
<button
type="button"
class="shrink-0 p-1 text-muted-foreground hover:text-destructive"
onclick={() => removeHeaderPair(index)}
aria-label="Remove header"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
{/each}
</div>
{:else}
<p class="text-xs text-muted-foreground">No custom headers configured.</p>
{/if}
</div>
<KeyValuePairs
pairs={headerPairs}
onPairsChange={updateHeaderPairs}
keyPlaceholder="Header name"
valuePlaceholder="Value"
addButtonLabel="Add"
emptyMessage="No custom headers configured."
sectionLabel="Custom Headers"
sectionLabelOptional={true}
/>
</div>

View File

@ -0,0 +1,105 @@
<script lang="ts">
import { Plus, X } from '@lucide/svelte';
import { Input } from '$lib/components/ui/input';
import { autoResizeTextarea } from '$lib/utils';
import type { KeyValuePair } from '$lib/types';
interface Props {
pairs: KeyValuePair[];
onPairsChange: (pairs: KeyValuePair[]) => void;
keyPlaceholder?: string;
valuePlaceholder?: string;
addButtonLabel?: string;
emptyMessage?: string;
sectionLabel?: string;
sectionLabelOptional?: boolean;
}
let {
pairs,
onPairsChange,
keyPlaceholder = 'Key',
valuePlaceholder = 'Value',
addButtonLabel = 'Add',
emptyMessage = 'No items configured.',
sectionLabel,
sectionLabelOptional = true
}: Props = $props();
function addPair() {
onPairsChange([...pairs, { key: '', value: '' }]);
}
function removePair(index: number) {
onPairsChange(pairs.filter((_, i) => i !== index));
}
function updatePairKey(index: number, key: string) {
const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], key };
onPairsChange(newPairs);
}
function updatePairValue(index: number, value: string) {
const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], value };
onPairsChange(newPairs);
}
</script>
<div>
<div class="mb-1 flex items-center justify-between">
{#if sectionLabel}
<span class="text-xs font-medium">
{sectionLabel}
{#if sectionLabelOptional}
<span class="text-muted-foreground">(optional)</span>
{/if}
</span>
{/if}
<button
type="button"
class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
onclick={addPair}
>
<Plus class="h-3 w-3" />
{addButtonLabel}
</button>
</div>
{#if pairs.length > 0}
<div class="space-y-2">
{#each pairs as pair, index (index)}
<div class="flex items-start gap-2">
<Input
type="text"
placeholder={keyPlaceholder}
value={pair.key}
oninput={(e) => updatePairKey(index, e.currentTarget.value)}
class="flex-1"
/>
<textarea
placeholder={valuePlaceholder}
value={pair.value}
oninput={(e) => {
updatePairValue(index, e.currentTarget.value);
autoResizeTextarea(e.currentTarget);
}}
class="flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm leading-5 placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
rows="1"
></textarea>
<button
type="button"
class="shrink-0 p-1 text-muted-foreground hover:text-destructive"
onclick={() => removePair(index)}
aria-label="Remove item"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
{/each}
</div>
{:else}
<p class="text-xs text-muted-foreground">{emptyMessage}</p>
{/if}
</div>

View File

@ -0,0 +1,12 @@
/**
* Common utility types used across the application
*/
/**
* Represents a key-value pair.
* Used for headers, environment variables, query parameters, etc.
*/
export interface KeyValuePair {
key: string;
value: string;
}

View File

@ -66,3 +66,6 @@ export type {
SettingsChatServiceOptions,
SettingsConfigType
} from './settings';
// Common types
export type { KeyValuePair } from './common';

View File

@ -9,11 +9,6 @@ import type { McpServerOverride } from '$lib/types/database';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import { normalizePositiveNumber } from '$lib/utils/number';
/**
* Represents a key-value pair for HTTP headers.
*/
export type HeaderPair = { key: string; value: string };
/**
* Detects the MCP transport type from a URL.
* WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'.
@ -84,7 +79,7 @@ export function getFaviconUrl(serverUrl: string): string | null {
* Parses a JSON string of headers into an array of key-value pairs.
* Returns empty array if the JSON is invalid or empty.
*/
export function parseHeadersToArray(headersJson: string): HeaderPair[] {
export function parseHeadersToArray(headersJson: string): { key: string; value: string }[] {
if (!headersJson?.trim()) return [];
try {
@ -106,7 +101,7 @@ export function parseHeadersToArray(headersJson: string): HeaderPair[] {
* Serializes an array of header key-value pairs to a JSON string.
* Filters out pairs with empty keys and returns empty string if no valid pairs.
*/
export function serializeHeaders(pairs: HeaderPair[]): string {
export function serializeHeaders(pairs: { key: string; value: string }[]): string {
const validPairs = pairs.filter((p) => p.key.trim());
if (validPairs.length === 0) return '';