Files
llm-multiverse-ui/src/lib/components/FinalResult.svelte
shahondin1624 2c6c961e08 feat: structured artifact rendering, UX improvements
- Render structured artifacts from agent results with type-aware
  formatting: code blocks with syntax highlighting and copy button,
  terminal-style command output, search result cards, and text findings
- Make FinalResult panel collapsible (default collapsed) with scrollable
  content (max-h-96) to prevent dominating the chat view
- Add clickable URL detection in summaries and artifact content
- Fix code block contrast for both light and dark mode
- Animate progress bar with pulse ring on active step and gradient
  shimmer on connecting lines
- Fix tab-switching bug: use module-level orchestrationStore singleton
  so orchestration state survives route navigation
- Remove sample/demo data seeding and clean up persisted localStorage
  entries from previous sample sessions
- Remove showSampleBadge prop from PageHeader
- Regenerate proto types for new Artifact message and ArtifactType enum
- Update README project structure (remove deleted data/ directory)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:13:33 +01:00

183 lines
9.1 KiB
Svelte

<script lang="ts">
import type { SubagentResult } from '$lib/proto/llm_multiverse/v1/common_pb';
import { ResultStatus, ResultQuality, ArtifactType } from '$lib/proto/llm_multiverse/v1/common_pb';
import { resultSourceConfig } from '$lib/types/resultSource';
let { result }: { result: SubagentResult } = $props();
const statusConfig = $derived.by(() => {
switch (result.status) {
case ResultStatus.SUCCESS:
return { label: 'Success', bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-800 dark:text-green-300', border: 'border-green-200 dark:border-green-800' };
case ResultStatus.PARTIAL:
return { label: 'Partial', bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-800 dark:text-amber-300', border: 'border-amber-200 dark:border-amber-800' };
case ResultStatus.FAILED:
return { label: 'Failed', bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-800 dark:text-red-300', border: 'border-red-200 dark:border-red-800' };
default:
return { label: 'Unknown', bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-800 dark:text-gray-300', border: 'border-gray-200 dark:border-gray-700' };
}
});
const qualityLabel = $derived.by(() => {
switch (result.resultQuality) {
case ResultQuality.VERIFIED: return 'Verified';
case ResultQuality.INFERRED: return 'Inferred';
case ResultQuality.UNCERTAIN: return 'Uncertain';
default: return '';
}
});
const sourceBadge = $derived(resultSourceConfig(result.source));
const LINE_COLLAPSE_THRESHOLD = 20;
function contentLineCount(content: string): number {
return content.split('\n').length;
}
const URL_RE = /(https?:\/\/[^\s<>"')\]]+)/g;
function linkify(text: string): string {
return text.replace(URL_RE, '<a href="$1" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 underline hover:text-blue-800 dark:hover:text-blue-300 break-all">$1</a>');
}
let copiedIndex: number | null = $state(null);
async function copyToClipboard(content: string, index: number) {
await navigator.clipboard.writeText(content);
copiedIndex = index;
setTimeout(() => {
if (copiedIndex === index) copiedIndex = null;
}, 2000);
}
</script>
<details class="mx-4 mb-3 rounded-xl border {statusConfig.border} {statusConfig.bg}">
<summary class="flex cursor-pointer items-center gap-2 px-4 py-2.5 select-none">
<svg class="chevron h-4 w-4 shrink-0 text-gray-500 dark:text-gray-400 transition-transform" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
<span class="rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig.bg} {statusConfig.text}">
{statusConfig.label}
</span>
{#if qualityLabel}
<span class="rounded-full bg-blue-100 dark:bg-blue-900/40 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:text-blue-300">
{qualityLabel}
</span>
{/if}
{#if sourceBadge.label !== 'Unspecified'}
<span class="rounded-full {sourceBadge.bg} px-2.5 py-0.5 text-xs font-medium {sourceBadge.text}">
{sourceBadge.label}
</span>
{/if}
{#if result.summary}
<span class="ml-1 truncate text-xs {statusConfig.text}">{result.summary}</span>
{/if}
</summary>
<div class="max-h-96 overflow-y-auto border-t {statusConfig.border} px-4 pb-4 pt-3">
{#if result.summary}
<p class="text-sm {statusConfig.text}">{@html linkify(result.summary)}</p>
{/if}
{#if result.failureReason}
<p class="mt-2 text-sm text-red-700 dark:text-red-400">Reason: {result.failureReason}</p>
{/if}
{#if result.artifacts.length > 0}
<div class="mt-3 border-t {statusConfig.border} pt-3">
<p class="mb-1.5 text-xs font-medium text-gray-600 dark:text-gray-400">Artifacts</p>
<div class="space-y-3">
{#each result.artifacts as artifact, i (artifact.label + i)}
{#if artifact.artifactType === ArtifactType.CODE}
<!-- Code artifact: syntax-highlighted block with filename header -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="flex items-center gap-2 bg-gray-100 dark:bg-gray-800 px-3 py-1.5 text-xs">
<span class="text-blue-600 dark:text-blue-400">&#128196;</span>
<span class="font-mono font-medium text-gray-700 dark:text-gray-300">{artifact.label}</span>
<div class="ml-auto flex items-center gap-1.5">
{#if artifact.metadata?.language}
<span class="rounded bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 text-[10px] text-gray-500 dark:text-gray-400">{artifact.metadata.language}</span>
{/if}
<button
type="button"
onclick={() => copyToClipboard(artifact.content, i)}
class="rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition-colors"
title="Copy code"
>
{#if copiedIndex === i}
<svg class="h-3.5 w-3.5 text-green-500" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>
{:else}
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" /></svg>
{/if}
</button>
</div>
</div>
{#if contentLineCount(artifact.content) > LINE_COLLAPSE_THRESHOLD}
<details>
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-900 px-3 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
Show {contentLineCount(artifact.content)} lines
</summary>
<pre class="overflow-x-auto bg-gray-50 dark:bg-gray-900 p-3 text-xs leading-relaxed text-gray-800 dark:text-gray-200"><code class="language-{artifact.metadata?.language ?? ''}">{artifact.content}</code></pre>
</details>
{:else}
<pre class="overflow-x-auto bg-gray-50 dark:bg-gray-900 p-3 text-xs leading-relaxed text-gray-800 dark:text-gray-200"><code class="language-{artifact.metadata?.language ?? ''}">{artifact.content}</code></pre>
{/if}
</div>
{:else if artifact.artifactType === ArtifactType.COMMAND_OUTPUT}
<!-- Command output: terminal-style block -->
<div class="rounded-lg border border-gray-700 dark:border-gray-600 overflow-hidden">
<div class="flex items-center gap-2 bg-gray-800 dark:bg-gray-900 px-3 py-1.5 text-xs">
<span class="text-green-400">&#9654;</span>
<span class="font-mono text-gray-300">{artifact.label}</span>
</div>
{#if contentLineCount(artifact.content) > LINE_COLLAPSE_THRESHOLD}
<details>
<summary class="cursor-pointer bg-gray-900 dark:bg-black px-3 py-1 text-xs text-gray-400 hover:text-gray-200">
Show {contentLineCount(artifact.content)} lines
</summary>
<pre class="overflow-x-auto bg-gray-900 dark:bg-black p-3 font-mono text-xs leading-relaxed text-green-300 dark:text-green-400">{artifact.content}</pre>
</details>
{:else}
<pre class="overflow-x-auto bg-gray-900 dark:bg-black p-3 font-mono text-xs leading-relaxed text-green-300 dark:text-green-400">{artifact.content}</pre>
{/if}
</div>
{:else if artifact.artifactType === ArtifactType.SEARCH_RESULT}
<!-- Search result: card with query title -->
<div class="rounded-lg border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20 p-3">
<div class="mb-1 flex items-center gap-2 text-xs">
<span class="text-purple-500">&#128269;</span>
<span class="font-medium text-purple-700 dark:text-purple-300">{artifact.label}</span>
</div>
{#if contentLineCount(artifact.content) > LINE_COLLAPSE_THRESHOLD}
<details>
<summary class="cursor-pointer text-xs text-purple-500 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300">
Show full results
</summary>
<p class="mt-1 whitespace-pre-wrap text-xs text-gray-700 dark:text-gray-300">{@html linkify(artifact.content)}</p>
</details>
{:else}
<p class="whitespace-pre-wrap text-xs text-gray-700 dark:text-gray-300">{@html linkify(artifact.content)}</p>
{/if}
</div>
{:else}
<!-- Text / finding artifact: plain text -->
<div class="flex items-start gap-2 text-sm">
<span class="mt-0.5 text-gray-400 dark:text-gray-500">&#128196;</span>
<span class="text-xs text-gray-700 dark:text-gray-300">{@html linkify(artifact.content)}</span>
</div>
{/if}
{/each}
</div>
</div>
{/if}
</div>
</details>
<style>
details[open] > summary .chevron {
transform: rotate(90deg);
}
</style>