feat(frontend): add Phase 3 UI components for observability

Components created:
- ValidationWarningsAlert: Display validation warnings with collapsible list
- StructuredErrorDisplay: Parse and display HTTP 502 errors as bullet points
- AutoFixBadge: Show automatically applied fixes
- ReasoningChainViewer: Collapsible panel for LLM thinking process
- MetadataDebugPanel: Collapsible debug info (model, duration, token metrics)

CSS changes:
- Add .editorial-alert--warning and .editorial-alert--info variants

Integration:
- Update routines/suggest page to show warnings, auto-fixes, reasoning, and metadata
- Update products/suggest page with same observability components
- Replace plain error divs with StructuredErrorDisplay for better UX

All components follow design system and pass svelte-check with 0 errors
This commit is contained in:
Piotr Oleszczyk 2026-03-06 15:53:46 +01:00
parent 3c3248c2ea
commit 5d3f876bec
8 changed files with 310 additions and 3 deletions

View file

@ -277,6 +277,18 @@ body {
color: hsl(136 48% 26%);
}
.editorial-alert--warning {
border-color: hsl(42 78% 68%);
background: hsl(45 86% 92%);
color: hsl(36 68% 28%);
}
.editorial-alert--info {
border-color: hsl(204 56% 70%);
background: hsl(207 72% 93%);
color: hsl(207 78% 28%);
}
.products-table-shell {
border: 1px solid hsl(35 24% 74% / 0.85);
border-radius: 0.9rem;

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { Sparkles } from 'lucide-svelte';
interface Props {
autoFixes: string[];
}
let { autoFixes }: Props = $props();
</script>
{#if autoFixes && autoFixes.length > 0}
<div class="editorial-alert editorial-alert--success">
<div class="flex items-start gap-2">
<Sparkles class="size-5 shrink-0 mt-0.5" />
<div class="flex-1">
<p class="font-medium mb-1">Automatically adjusted</p>
<ul class="list-disc list-inside space-y-1 text-sm">
{#each autoFixes as fix}
<li>{fix}</li>
{/each}
</ul>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,83 @@
<script lang="ts">
import { Info, ChevronDown, ChevronRight } from 'lucide-svelte';
import type { ResponseMetadata } from '$lib/types';
interface Props {
metadata?: ResponseMetadata;
}
let { metadata }: Props = $props();
let expanded = $state(false);
function formatNumber(num: number): string {
return num.toLocaleString();
}
</script>
{#if metadata}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
onclick={() => (expanded = !expanded)}
class="w-full flex items-center gap-2 px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors"
>
<Info class="size-4 text-gray-600" />
<span class="text-sm font-medium text-gray-700">Debug Information</span>
<div class="ml-auto">
{#if expanded}
<ChevronDown class="size-4 text-gray-500" />
{:else}
<ChevronRight class="size-4 text-gray-500" />
{/if}
</div>
</button>
{#if expanded}
<div class="p-4 bg-white border-t border-gray-200">
<dl class="grid grid-cols-1 gap-3 text-sm">
<div>
<dt class="font-medium text-gray-700">Model</dt>
<dd class="text-gray-600 font-mono text-xs mt-0.5">{metadata.model_used}</dd>
</div>
<div>
<dt class="font-medium text-gray-700">Duration</dt>
<dd class="text-gray-600">{formatNumber(metadata.duration_ms)} ms</dd>
</div>
{#if metadata.token_metrics}
<div>
<dt class="font-medium text-gray-700">Token Usage</dt>
<dd class="text-gray-600 space-y-1 mt-0.5">
<div class="flex justify-between">
<span>Prompt:</span>
<span class="font-mono text-xs"
>{formatNumber(metadata.token_metrics.prompt_tokens)}</span
>
</div>
<div class="flex justify-between">
<span>Completion:</span>
<span class="font-mono text-xs"
>{formatNumber(metadata.token_metrics.completion_tokens)}</span
>
</div>
{#if metadata.token_metrics.thoughts_tokens}
<div class="flex justify-between">
<span>Thinking:</span>
<span class="font-mono text-xs"
>{formatNumber(metadata.token_metrics.thoughts_tokens)}</span
>
</div>
{/if}
<div class="flex justify-between font-medium border-t border-gray-200 pt-1 mt-1">
<span>Total:</span>
<span class="font-mono text-xs"
>{formatNumber(metadata.token_metrics.total_tokens)}</span
>
</div>
</dd>
</div>
{/if}
</dl>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { Brain, ChevronDown, ChevronRight } from 'lucide-svelte';
interface Props {
reasoningChain?: string;
}
let { reasoningChain }: Props = $props();
let expanded = $state(false);
</script>
{#if reasoningChain}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
onclick={() => (expanded = !expanded)}
class="w-full flex items-center gap-2 px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors"
>
<Brain class="size-4 text-gray-600" />
<span class="text-sm font-medium text-gray-700">AI Reasoning Process</span>
<div class="ml-auto">
{#if expanded}
<ChevronDown class="size-4 text-gray-500" />
{:else}
<ChevronRight class="size-4 text-gray-500" />
{/if}
</div>
</button>
{#if expanded}
<div class="p-4 bg-gray-50 border-t border-gray-200">
<pre
class="text-xs font-mono whitespace-pre-wrap text-gray-700 leading-relaxed">{reasoningChain}</pre>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { XCircle } from 'lucide-svelte';
interface Props {
error: string;
}
let { error }: Props = $props();
// Parse semicolon-separated errors from backend validation failures
const errors = $derived(
error.includes(';')
? error
.split(';')
.map((e) => e.trim())
.filter((e) => e.length > 0)
: [error]
);
// Extract prefix if present (e.g., "Generated routine failed safety validation: ")
const hasPrefix = $derived(errors.length === 1 && errors[0].includes(': '));
const prefix = $derived(
hasPrefix ? errors[0].substring(0, errors[0].indexOf(': ') + 1) : ''
);
const cleanedErrors = $derived(
hasPrefix && prefix ? [errors[0].substring(prefix.length)] : errors
);
</script>
<div class="editorial-alert editorial-alert--error">
<div class="flex items-start gap-2">
<XCircle class="size-5 shrink-0 mt-0.5" />
<div class="flex-1">
{#if prefix}
<p class="font-medium mb-2">{prefix.replace(':', '')}</p>
{/if}
{#if cleanedErrors.length === 1}
<p>{cleanedErrors[0]}</p>
{:else}
<ul class="list-disc list-inside space-y-1">
{#each cleanedErrors as err}
<li>{err}</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { AlertTriangle } from 'lucide-svelte';
interface Props {
warnings: string[];
}
let { warnings }: Props = $props();
let expanded = $state(false);
const shouldCollapse = $derived(warnings.length > 3);
const displayedWarnings = $derived(
expanded || !shouldCollapse ? warnings : warnings.slice(0, 3)
);
</script>
{#if warnings && warnings.length > 0}
<div class="editorial-alert editorial-alert--warning">
<div class="flex items-start gap-2">
<AlertTriangle class="size-5 shrink-0 mt-0.5" />
<div class="flex-1">
<p class="font-medium mb-1">Validation Warnings</p>
<ul class="list-disc list-inside space-y-1 text-sm">
{#each displayedWarnings as warning}
<li>{warning}</li>
{/each}
</ul>
{#if shouldCollapse}
<button
type="button"
onclick={() => (expanded = !expanded)}
class="text-sm underline mt-2 hover:no-underline"
>
{expanded ? 'Show less' : `Show ${warnings.length - 3} more`}
</button>
{/if}
</div>
</div>
</div>
{/if}

View file

@ -1,17 +1,26 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import type { ProductSuggestion } from '$lib/types';
import type { ProductSuggestion, ResponseMetadata } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Sparkles, ArrowLeft } from 'lucide-svelte';
import ValidationWarningsAlert from '$lib/components/ValidationWarningsAlert.svelte';
import StructuredErrorDisplay from '$lib/components/StructuredErrorDisplay.svelte';
import AutoFixBadge from '$lib/components/AutoFixBadge.svelte';
import ReasoningChainViewer from '$lib/components/ReasoningChainViewer.svelte';
import MetadataDebugPanel from '$lib/components/MetadataDebugPanel.svelte';
let suggestions = $state<ProductSuggestion[] | null>(null);
let reasoning = $state('');
let loading = $state(false);
let errorMsg = $state<string | null>(null);
// Phase 3: Observability state
let validationWarnings = $state<string[] | undefined>(undefined);
let autoFixes = $state<string[] | undefined>(undefined);
let metadata = $state<ResponseMetadata | undefined>(undefined);
function enhanceForm() {
loading = true;
@ -21,6 +30,10 @@
if (result.type === 'success' && result.data?.suggestions) {
suggestions = result.data.suggestions as ProductSuggestion[];
reasoning = result.data.reasoning as string;
// Phase 3: Extract observability data
validationWarnings = result.data.validation_warnings as string[] | undefined;
autoFixes = result.data.auto_fixes_applied as string[] | undefined;
metadata = result.data.metadata as ResponseMetadata | undefined;
errorMsg = null;
} else if (result.type === 'failure') {
errorMsg = (result.data?.error as string) ?? m["suggest_errorDefault"]();
@ -41,7 +54,7 @@
</section>
{#if errorMsg}
<div class="editorial-alert editorial-alert--error">{errorMsg}</div>
<StructuredErrorDisplay error={errorMsg} />
{/if}
<Card class="reveal-2">
@ -72,6 +85,22 @@
</Card>
{/if}
<!-- Phase 3: Observability components -->
<div class="space-y-4 reveal-3">
{#if autoFixes}
<AutoFixBadge autoFixes={autoFixes} />
{/if}
{#if validationWarnings}
<ValidationWarningsAlert warnings={validationWarnings} />
{/if}
{#if metadata?.reasoning_chain}
<ReasoningChainViewer reasoningChain={metadata.reasoning_chain} />
{/if}
{#if metadata}
<MetadataDebugPanel {metadata} />
{/if}
</div>
<div class="space-y-4 reveal-3">
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
{#each suggestions as s (s.product_type)}

View file

@ -16,6 +16,11 @@
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
import { ChevronUp, ChevronDown, ArrowLeft, Sparkles } from 'lucide-svelte';
import ValidationWarningsAlert from '$lib/components/ValidationWarningsAlert.svelte';
import StructuredErrorDisplay from '$lib/components/StructuredErrorDisplay.svelte';
import AutoFixBadge from '$lib/components/AutoFixBadge.svelte';
import ReasoningChainViewer from '$lib/components/ReasoningChainViewer.svelte';
import MetadataDebugPanel from '$lib/components/MetadataDebugPanel.svelte';
let { data }: { data: PageData } = $props();
@ -121,7 +126,7 @@
</section>
{#if errorMsg}
<div class="editorial-alert editorial-alert--error">{errorMsg}</div>
<StructuredErrorDisplay error={errorMsg} />
{/if}
<Tabs value="single" class="reveal-2 editorial-tabs">
@ -215,6 +220,20 @@
</Card>
{/if}
<!-- Phase 3: Observability components -->
{#if suggestion.auto_fixes_applied}
<AutoFixBadge autoFixes={suggestion.auto_fixes_applied} />
{/if}
{#if suggestion.validation_warnings}
<ValidationWarningsAlert warnings={suggestion.validation_warnings} />
{/if}
{#if suggestion.metadata?.reasoning_chain}
<ReasoningChainViewer reasoningChain={suggestion.metadata.reasoning_chain} />
{/if}
{#if suggestion.metadata}
<MetadataDebugPanel metadata={suggestion.metadata} />
{/if}
<!-- Steps -->
<div class="space-y-2">
{#each suggestion.steps as step, i (i)}
@ -323,6 +342,20 @@
</Card>
{/if}
<!-- Phase 3: Observability components -->
{#if batch.auto_fixes_applied}
<AutoFixBadge autoFixes={batch.auto_fixes_applied} />
{/if}
{#if batch.validation_warnings}
<ValidationWarningsAlert warnings={batch.validation_warnings} />
{/if}
{#if batch.metadata?.reasoning_chain}
<ReasoningChainViewer reasoningChain={batch.metadata.reasoning_chain} />
{/if}
{#if batch.metadata}
<MetadataDebugPanel metadata={batch.metadata} />
{/if}
<!-- Day cards -->
<div class="space-y-3">
{#each batch.days as day (day.date)}