- 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>
183 lines
9.1 KiB
Svelte
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">📄</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">▶</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">🔍</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">📄</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>
|