Files
synapbus/web/src/lib/components/Sidebar.svelte
T
Algis DumbrisandClaude Opus 4.6 c6c96f64be feat(014): add Web UI Agent Runs page
- New /runs route with agent summary cards, run list, filtering
- Agent cards show budget usage, cooldown status, current state
- Expandable run rows with error logs and retry button
- API client: runs.list, runs.get, runs.retry, runs.reactiveAgents
- Sidebar navigation updated with "Agent Runs" link
- Rebuilt web dist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:45:57 +02:00

328 lines
14 KiB
Svelte

<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { user, logout } from '$lib/stores/auth';
import { notifications } from '$lib/stores/notifications';
import { channels as channelsApi, agents as agentsApi, deadLetters as deadLettersApi } from '$lib/api/client';
let { open = false, onclose = () => {} }: { open?: boolean; onclose?: () => void } = $props();
let channelList = $state<any[]>([]);
let agentList = $state<any[]>([]);
let deadLetterCount = $state(0);
let channelsExpanded = $state(true);
let dmsExpanded = $state(true);
let adminExpanded = $state(true);
let _initialized = $state(false);
$effect(() => {
if (!_initialized && $user) {
_initialized = true;
loadSidebarData();
}
});
async function loadSidebarData() {
try {
const [chRes, agRes, dlRes] = await Promise.all([
channelsApi.list(),
agentsApi.list(),
deadLettersApi.count().catch(() => ({ count: 0 }))
]);
channelList = chRes.channels ?? [];
agentList = agRes.agents ?? [];
deadLetterCount = dlRes.count ?? 0;
} catch {
// handled
}
}
function handleNavClick() {
onclose();
}
function isActive(href: string): boolean {
return $page.url.pathname === href || ($page.url.pathname.startsWith(href) && href !== '/');
}
async function handleLogout() {
await logout();
goto('/login');
}
function badgeText(count: number): string {
return count > 99 ? '99+' : String(count);
}
// Filter DM list: only show human agents + AI agents with unread DMs.
// This hides agent-to-agent internal conversations from the sidebar.
let dmAgentList = $derived(
agentList.filter(agent => {
if (agent.type !== 'ai') return true;
const unread = $notifications.dms.get(agent.name) ?? 0;
return unread > 0;
})
);
const adminLinks = [
{ href: '/agents', label: 'Agents' },
{ href: '/runs', label: 'Agent Runs' },
{ href: '/skills', label: 'Skills' },
{ href: '/settings', label: 'Settings' }
];
</script>
<!-- Mobile overlay backdrop -->
{#if open}
<button
class="fixed inset-0 z-30 bg-black/50 md:hidden"
onclick={onclose}
aria-label="Close sidebar"
tabindex="-1"
></button>
{/if}
<aside class="fixed top-0 left-0 z-40 h-screen w-[260px] bg-bg-secondary border-r border-border flex flex-col select-none transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'} md:translate-x-0">
<!-- Workspace header -->
<div class="flex items-center gap-2.5 h-14 px-4 border-b border-border flex-shrink-0">
<div class="w-7 h-7 rounded-lg bg-gradient-to-br from-accent-purple to-[#06b6d4] flex items-center justify-center">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="5" r="1.8" fill="#c4b5fd" />
<circle cx="6" cy="10" r="1.5" fill="#a78bfa" />
<circle cx="18" cy="9" r="1.5" fill="#c4b5fd" />
<circle cx="12" cy="13" r="2" fill="#67e8f9" />
<circle cx="5" cy="17" r="1.5" fill="#a78bfa" />
<circle cx="19" cy="17" r="1.3" fill="#a78bfa" />
<circle cx="11" cy="20" r="1.3" fill="#c4b5fd" />
<line x1="12" y1="5" x2="6" y2="10" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="12" y1="5" x2="18" y2="9" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="6" y1="10" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="18" y1="9" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="12" y1="13" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="12" y1="13" x2="19" y2="17" stroke="white" stroke-width="0.5" opacity="0.5" />
<line x1="5" y1="17" x2="11" y2="20" stroke="white" stroke-width="0.5" opacity="0.3" />
<line x1="6" y1="10" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.3" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h1 class="text-sm font-bold text-text-primary font-display truncate">SynapBus</h1>
<p class="text-[10px] text-text-secondary leading-tight">Mission Control</p>
</div>
<!-- User avatar -->
<button
class="w-7 h-7 rounded-full bg-accent-green/20 flex items-center justify-center text-accent-green text-xs font-bold hover:bg-accent-green/30 transition-colors"
onclick={handleLogout}
title="Logout"
>
{$user?.username?.charAt(0).toUpperCase() ?? '?'}
</button>
</div>
<!-- Scrollable nav -->
<nav class="flex-1 overflow-y-auto px-2 pb-3">
<!-- Dashboard -->
<a
href="/"
class="sidebar-item mb-1 {isActive('/') && $page.url.pathname === '/' ? 'sidebar-item-active' : ''}"
onclick={handleNavClick}
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard
</a>
<!-- Search -->
<a
href="/conversations"
class="sidebar-item mb-3 {isActive('/conversations') ? 'sidebar-item-active' : ''}"
onclick={handleNavClick}
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Search
</a>
<!-- Channels section -->
<div class="mb-3">
<button
class="section-header w-full hover:text-text-primary transition-colors"
onclick={() => (channelsExpanded = !channelsExpanded)}
>
<span class="flex items-center gap-1">
<svg class="w-3 h-3 transition-transform {channelsExpanded ? 'rotate-0' : '-rotate-90'}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Channels
</span>
<a
href="/channels"
class="text-text-secondary hover:text-text-primary p-0.5"
onclick={(e) => e.stopPropagation()}
title="Manage channels"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</a>
</button>
{#if channelsExpanded}
<div class="mt-0.5">
{#if channelList.length === 0}
<p class="px-3 py-1 text-xs text-text-secondary italic">No channels</p>
{:else}
{#each channelList as ch}
{@const chUnread = $notifications.channels.get(ch.name) ?? 0}
<a
href="/channels/{ch.name}"
class="sidebar-item {isActive('/channels/' + ch.name) ? 'sidebar-item-active' : ''}"
onclick={handleNavClick}
>
{#if ch.name.startsWith('my-agents-')}
<svg class="w-4 h-4 flex-shrink-0 text-accent-purple" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="truncate {chUnread > 0 ? 'font-bold text-text-primary' : ''}">My Agents</span>
{:else}
<span class="text-text-secondary font-mono text-xs">#</span>
<span class="truncate {chUnread > 0 ? 'font-bold text-text-primary' : ''}">{ch.name}</span>
{/if}
{#if ch.is_private && !ch.name.startsWith('my-agents-')}
<svg class="w-3 h-3 text-text-secondary {chUnread > 0 ? '' : 'ml-auto'} flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
{/if}
{#if chUnread > 0}
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{badgeText(chUnread)}</span>
{/if}
</a>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Direct Messages (Agents) section -->
<div class="mb-3">
<button
class="section-header w-full hover:text-text-primary transition-colors"
onclick={() => (dmsExpanded = !dmsExpanded)}
>
<span class="flex items-center gap-1">
<svg class="w-3 h-3 transition-transform {dmsExpanded ? 'rotate-0' : '-rotate-90'}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Direct Messages
</span>
</button>
{#if dmsExpanded}
<div class="mt-0.5">
{#if dmAgentList.length === 0}
<p class="px-3 py-1 text-xs text-text-secondary italic">No agents</p>
{:else}
{#each dmAgentList as agent}
{@const dmUnread = $notifications.dms.get(agent.name) ?? 0}
<a
href="/dm/{agent.name}"
class="sidebar-item {isActive('/dm/' + agent.name) ? 'sidebar-item-active' : ''}"
onclick={handleNavClick}
>
<span class="relative flex-shrink-0">
<span class="w-5 h-5 rounded-full bg-bg-tertiary flex items-center justify-center text-[10px] font-bold text-text-secondary">
{(agent.display_name || agent.name).charAt(0).toUpperCase()}
</span>
<span
class="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-secondary {agent.status === 'active' ? 'bg-accent-green' : 'bg-text-secondary'}"
></span>
</span>
<span class="truncate {dmUnread > 0 ? 'font-bold text-text-primary' : ''}">{agent.display_name || agent.name}</span>
<span class="text-[9px] text-text-secondary flex-shrink-0">(you)</span>
{#if dmUnread > 0}
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{badgeText(dmUnread)}</span>
{:else if agent.type === 'ai'}
<span class="ml-auto text-[9px] font-mono text-accent-purple bg-accent-purple/10 px-1 rounded flex-shrink-0">AI</span>
{/if}
</a>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Manage section -->
<div class="mb-3">
<button
class="section-header w-full hover:text-text-primary transition-colors"
onclick={() => (adminExpanded = !adminExpanded)}
>
<span class="flex items-center gap-1">
<svg class="w-3 h-3 transition-transform {adminExpanded ? 'rotate-0' : '-rotate-90'}" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Manage
</span>
</button>
{#if adminExpanded}
<div class="mt-0.5">
{#each adminLinks as link}
<a
href={link.href}
class="sidebar-item {isActive(link.href) ? 'sidebar-item-active' : ''}"
onclick={handleNavClick}
>
{#if link.label === 'Agents'}
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{:else if link.label === 'Skills'}
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
{:else if link.label === 'Settings'}
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{/if}
{link.label}
</a>
{/each}
<a
href="/dead-letters"
class="sidebar-item {isActive('/dead-letters') && $page.url.pathname === '/dead-letters' ? 'sidebar-item-active' : ''}"
onclick={handleNavClick}
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5 1.615a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V8.844a2.25 2.25 0 011.183-1.98l7.5-4.04a2.25 2.25 0 012.134 0l7.5 4.04a2.25 2.25 0 011.183 1.98V19.5z" />
</svg>
Dead Letters
{#if deadLetterCount > 0}
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{deadLetterCount}</span>
{/if}
</a>
<a
href="/dead-letters/webhooks"
class="sidebar-item {isActive('/dead-letters/webhooks') ? 'sidebar-item-active' : ''}"
onclick={handleNavClick}
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
Webhook Dead Letters
</a>
</div>
{/if}
</div>
</nav>
<!-- Bottom status -->
<div class="px-3 py-2 border-t border-border flex-shrink-0">
<div class="flex items-center gap-2 text-xs text-text-secondary">
<span class="w-2 h-2 rounded-full bg-accent-green"></span>
<span class="truncate">{$user?.display_name ?? $user?.username ?? 'Connected'}</span>
</div>
</div>
</aside>