Skip to content

Commit 0f226a4

Browse files
committed
feat: Better Error Handling UX
Adds a dedicated error splash screen for server connection issues, including API key validation and input. Removes the direct server error check from ChatScreen, relying on the new error handling logic. BONUS: Replaces generic labels with the ui/label component for consistent styling and accessibility.
1 parent a9bfe9e commit 0f226a4

File tree

14 files changed

+387
-27
lines changed

14 files changed

+387
-27
lines changed

tools/server/public/index.html.gz

3.23 KB
Binary file not shown.

tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { Checkbox } from '$lib/components/ui/checkbox';
99
import { inputClasses } from '$lib/constants/input-classes';
1010
import ChatMessageActions from './ChatMessageActions.svelte';
11+
import Label from '$lib/components/ui/label/label.svelte';
1112
1213
interface Props {
1314
class?: string;
@@ -110,9 +111,9 @@
110111
bind:checked={shouldBranchAfterEdit}
111112
onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)}
112113
/>
113-
<label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
114+
<Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
114115
Branch conversation after edit
115-
</label>
116+
</Label>
116117
</div>
117118
<div class="flex gap-2">
118119
<Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">

tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
ChatProcessingInfo,
1818
EmptyFileAlertDialog,
1919
ServerInfo,
20-
ServerErrorSplash,
2120
ServerLoadingSplash
2221
} from '$lib/components/app';
2322
import {
@@ -70,12 +69,11 @@
7069
let showEmptyFileDialog = $state(false);
7170
let emptyFileNames = $state<string[]>([]);
7271
73-
const isEmpty = $derived(
72+
let isEmpty = $derived(
7473
showCenteredEmpty && !activeConversation() && activeMessages().length === 0 && !isLoading()
7574
);
7675
77-
const hasServerError = $derived(serverError());
78-
const isServerLoading = $derived(serverLoading());
76+
let isServerLoading = $derived(serverLoading());
7977
8078
function handleDragEnter(event: DragEvent) {
8179
event.preventDefault();
@@ -322,9 +320,6 @@
322320
</div>
323321
</div>
324322
</div>
325-
{:else if hasServerError}
326-
<!-- Server Error State -->
327-
<ServerErrorSplash error={hasServerError} />
328323
{:else if isServerLoading}
329324
<!-- Server Loading State -->
330325
<ServerLoadingSplash />

tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { config, updateMultipleConfig, resetConfig } from '$lib/stores/settings.svelte';
1313
import { setMode } from 'mode-watcher';
1414
import type { Component } from 'svelte';
15+
import Label from '$lib/components/ui/label/label.svelte';
1516
1617
interface Props {
1718
onOpenChange?: (open: boolean) => void;
@@ -337,9 +338,9 @@
337338
{#each currentSection.fields as field (field.key)}
338339
<div class="space-y-2">
339340
{#if field.type === 'input'}
340-
<label for={field.key} class="block text-sm font-medium">
341+
<Label for={field.key} class="block text-sm font-medium">
341342
{field.label}
342-
</label>
343+
</Label>
343344

344345
<Input
345346
id={field.key}
@@ -354,9 +355,9 @@
354355
</p>
355356
{/if}
356357
{:else if field.type === 'textarea'}
357-
<label for={field.key} class="block text-sm font-medium">
358+
<Label for={field.key} class="block text-sm font-medium">
358359
{field.label}
359-
</label>
360+
</Label>
360361

361362
<Textarea
362363
id={field.key}
@@ -376,9 +377,9 @@
376377
opt.value === localConfig[field.key]
377378
)}
378379

379-
<label for={field.key} class="block text-sm font-medium">
380+
<Label for={field.key} class="block text-sm font-medium">
380381
{field.label}
381-
</label>
382+
</Label>
382383

383384
<Select.Root
384385
type="single"

tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte

Lines changed: 187 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
<script lang="ts">
22
import { Button } from '$lib/components/ui/button';
3-
import { AlertTriangle, RefreshCw } from '@lucide/svelte';
3+
import { Input } from '$lib/components/ui/input';
4+
import Label from '$lib/components/ui/label/label.svelte';
5+
import { AlertTriangle, RefreshCw, Key, CheckCircle, XCircle } from '@lucide/svelte';
46
import { ServerStatus } from '$lib/components/app';
57
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
6-
import { fade, fly } from 'svelte/transition';
8+
import { config, updateConfig } from '$lib/stores/settings.svelte';
9+
import { goto } from '$app/navigation';
10+
import { fade, fly, scale } from 'svelte/transition';
711
812
interface Props {
913
class?: string;
@@ -18,10 +22,22 @@
1822
error,
1923
onRetry,
2024
showRetry = true,
21-
showTroubleshooting = true
25+
showTroubleshooting = false
2226
}: Props = $props();
2327
24-
const isServerLoading = $derived(serverLoading());
28+
let isServerLoading = $derived(serverLoading());
29+
let isAccessDeniedError = $derived(
30+
error.toLowerCase().includes('access denied') ||
31+
error.toLowerCase().includes('invalid api key') ||
32+
error.toLowerCase().includes('unauthorized') ||
33+
error.toLowerCase().includes('401') ||
34+
error.toLowerCase().includes('403')
35+
);
36+
37+
let apiKeyInput = $state('');
38+
let showApiKeyInput = $state(false);
39+
let apiKeyState = $state<'idle' | 'validating' | 'success' | 'error'>('idle');
40+
let apiKeyError = $state('');
2541
2642
function handleRetryConnection() {
2743
if (onRetry) {
@@ -30,6 +46,81 @@
3046
serverStore.fetchServerProps();
3147
}
3248
}
49+
50+
function handleShowApiKeyInput() {
51+
showApiKeyInput = true;
52+
// Pre-fill with current API key if it exists
53+
const currentConfig = config();
54+
apiKeyInput = currentConfig.apiKey?.toString() || '';
55+
}
56+
57+
async function handleSaveApiKey() {
58+
if (!apiKeyInput.trim()) return;
59+
60+
apiKeyState = 'validating';
61+
apiKeyError = '';
62+
63+
try {
64+
// Update the API key in settings first
65+
updateConfig('apiKey', apiKeyInput.trim());
66+
67+
// Test the API key by making a real request to the server
68+
const response = await fetch('/props', {
69+
headers: {
70+
'Content-Type': 'application/json',
71+
Authorization: `Bearer ${apiKeyInput.trim()}`
72+
}
73+
});
74+
75+
if (response.ok) {
76+
// API key is valid - User Story B
77+
apiKeyState = 'success';
78+
79+
// Show success state briefly, then navigate to home
80+
setTimeout(() => {
81+
goto('/');
82+
}, 1000);
83+
} else {
84+
// API key is invalid - User Story A
85+
apiKeyState = 'error';
86+
87+
if (response.status === 401 || response.status === 403) {
88+
apiKeyError = 'Invalid API key - please check and try again';
89+
} else {
90+
apiKeyError = `Authentication failed (${response.status})`;
91+
}
92+
93+
// Reset to idle state after showing error (don't reload UI)
94+
setTimeout(() => {
95+
apiKeyState = 'idle';
96+
}, 3000);
97+
}
98+
} catch (error) {
99+
// Network or other errors - User Story A
100+
apiKeyState = 'error';
101+
102+
if (error instanceof Error) {
103+
if (error.message.includes('fetch')) {
104+
apiKeyError = 'Cannot connect to server - check if server is running';
105+
} else {
106+
apiKeyError = error.message;
107+
}
108+
} else {
109+
apiKeyError = 'Connection error - please try again';
110+
}
111+
112+
// Reset to idle state after showing error (don't reload UI)
113+
setTimeout(() => {
114+
apiKeyState = 'idle';
115+
}, 3000);
116+
}
117+
}
118+
119+
function handleApiKeyKeydown(event: KeyboardEvent) {
120+
if (event.key === 'Enter') {
121+
handleSaveApiKey();
122+
}
123+
}
33124
</script>
34125

35126
<div class="flex h-full items-center justify-center {className}">
@@ -46,21 +137,108 @@
46137
<p class="mb-4 text-sm text-muted-foreground">
47138
{error}
48139
</p>
140+
</div>
49141

50-
<div class="mb-4">
51-
<ServerStatus showActions={true} class="justify-center" />
142+
{#if isAccessDeniedError && !showApiKeyInput}
143+
<div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4">
144+
<Button onclick={handleShowApiKeyInput} variant="outline" class="w-full">
145+
<Key class="h-4 w-4" />
146+
Enter API Key
147+
</Button>
52148
</div>
53-
</div>
149+
{/if}
150+
151+
{#if showApiKeyInput}
152+
<div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4 space-y-3 text-left">
153+
<div class="space-y-2">
154+
<Label for="api-key-input" class="text-sm font-medium">API Key</Label>
155+
156+
<div class="relative">
157+
<Input
158+
id="api-key-input"
159+
placeholder="Enter your API key..."
160+
bind:value={apiKeyInput}
161+
onkeydown={handleApiKeyKeydown}
162+
class="w-full pr-10 {apiKeyState === 'error'
163+
? 'border-destructive'
164+
: apiKeyState === 'success'
165+
? 'border-green-500'
166+
: ''}"
167+
disabled={apiKeyState === 'validating'}
168+
/>
169+
{#if apiKeyState === 'validating'}
170+
<div class="absolute top-1/2 right-3 -translate-y-1/2">
171+
<RefreshCw class="h-4 w-4 animate-spin text-muted-foreground" />
172+
</div>
173+
{:else if apiKeyState === 'success'}
174+
<div
175+
class="absolute top-1/2 right-3 -translate-y-1/2"
176+
in:scale={{ duration: 200, start: 0.8 }}
177+
>
178+
<CheckCircle class="h-4 w-4 text-green-500" />
179+
</div>
180+
{:else if apiKeyState === 'error'}
181+
<div
182+
class="absolute top-1/2 right-3 -translate-y-1/2"
183+
in:scale={{ duration: 200, start: 0.8 }}
184+
>
185+
<XCircle class="h-4 w-4 text-destructive" />
186+
</div>
187+
{/if}
188+
</div>
189+
{#if apiKeyError}
190+
<p class="text-sm text-destructive" in:fly={{ y: -10, duration: 200 }}>
191+
{apiKeyError}
192+
</p>
193+
{/if}
194+
{#if apiKeyState === 'success'}
195+
<p class="text-sm text-green-600" in:fly={{ y: -10, duration: 200 }}>
196+
✓ API key validated successfully! Connecting...
197+
</p>
198+
{/if}
199+
</div>
200+
<div class="flex gap-2">
201+
<Button
202+
onclick={handleSaveApiKey}
203+
disabled={!apiKeyInput.trim() ||
204+
apiKeyState === 'validating' ||
205+
apiKeyState === 'success'}
206+
class="flex-1"
207+
>
208+
{#if apiKeyState === 'validating'}
209+
<RefreshCw class="h-4 w-4 animate-spin" />
210+
Validating...
211+
{:else if apiKeyState === 'success'}
212+
Success!
213+
{:else}
214+
Save & Retry
215+
{/if}
216+
</Button>
217+
<Button
218+
onclick={() => {
219+
showApiKeyInput = false;
220+
apiKeyState = 'idle';
221+
apiKeyError = '';
222+
}}
223+
variant="outline"
224+
class="flex-1"
225+
disabled={apiKeyState === 'validating'}
226+
>
227+
Cancel
228+
</Button>
229+
</div>
230+
</div>
231+
{/if}
54232

55233
{#if showRetry}
56234
<div in:fly={{ y: 10, duration: 300, delay: 200 }}>
57235
<Button onclick={handleRetryConnection} disabled={isServerLoading} class="w-full">
58236
{#if isServerLoading}
59-
<RefreshCw class="mr-2 h-4 w-4 animate-spin" />
237+
<RefreshCw class="h-4 w-4 animate-spin" />
60238

61239
Connecting...
62240
{:else}
63-
<RefreshCw class="mr-2 h-4 w-4" />
241+
<RefreshCw class="h-4 w-4" />
64242

65243
Retry Connection
66244
{/if}

tools/server/webui/src/lib/components/app/server/ServerStatus.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
}
3434
</script>
3535

36-
<div class="flex items-center space-x-2 {className}">
36+
<div class="flex items-center space-x-3 {className}">
3737
<div class="flex items-center space-x-2">
3838
<div class="h-2 w-2 rounded-full {getStatusColor()}"></div>
3939

@@ -56,7 +56,7 @@
5656

5757
{#if showActions && error}
5858
<Button variant="outline" size="sm" class="text-destructive">
59-
<AlertTriangle class="mr-2 h-4 w-4" />
59+
<AlertTriangle class="h-4 w-4" />
6060

6161
{error}
6262
</Button>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import Root from './label.svelte';
2+
3+
export {
4+
Root,
5+
//
6+
Root as Label
7+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script lang="ts">
2+
import { Label as LabelPrimitive } from 'bits-ui';
3+
import { cn } from '$lib/components/ui/utils.js';
4+
5+
let {
6+
ref = $bindable(null),
7+
class: className,
8+
...restProps
9+
}: LabelPrimitive.RootProps = $props();
10+
</script>
11+
12+
<LabelPrimitive.Root
13+
bind:ref
14+
data-slot="label"
15+
class={cn(
16+
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
17+
className
18+
)}
19+
{...restProps}
20+
/>

tools/server/webui/src/lib/stores/server.svelte.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ class ServerStore {
150150
} else if (error.message.includes('404')) {
151151
errorMessage = 'Server endpoint not found';
152152
} else if (error.message.includes('403') || error.message.includes('401')) {
153-
errorMessage = 'Access denied - check server permissions';
153+
errorMessage = 'Access denied';
154154
}
155155
}
156156

0 commit comments

Comments
 (0)