Toolbar is now view-provided via useToolbar composable

Each view declares its toolbar groups and connection via provideToolbar().
AppToolbar injects and renders only what the active view needs.

- AgentsView: quad-view + themes + panels + chat connection
- TestsView: themes + test SSE connection (replaces raw EventSource)
- ViewerView: themes only
- Home/Login: no provide → toolbar hidden

useConnection() manages any SSE endpoint: connect, reconnect, state.
Tenant feature gating follows naturally — routes that don't exist
contribute no toolbar config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nico 2026-04-03 22:52:28 +02:00
parent 4abbe86963
commit 409d873aa0
5 changed files with 146 additions and 83 deletions

View File

@ -1,8 +1,8 @@
<template> <template>
<div v-if="isLoggedIn" class="app-toolbar"> <div v-if="isLoggedIn && toolbar" class="app-toolbar">
<!-- Group: Connection + Takeover --> <!-- Group: Connection + Takeover (only if view provides a connection) -->
<div class="toolbar-group"> <div v-if="conn" class="toolbar-group">
<button class="toolbar-btn" :class="{ active: connActive }" @click="toggleDropdown('conn')" :title="connLabel || 'Connection'"> <button class="toolbar-btn" :class="{ active: connActive }" @click="toggleDropdown('conn')" :title="connLabel || 'Connection'">
<WifiIcon class="w-6 h-6" /> <WifiIcon class="w-6 h-6" />
<span v-if="connLabel" class="toolbar-btn-label">{{ connLabel }}</span> <span v-if="connLabel" class="toolbar-btn-label">{{ connLabel }}</span>
@ -14,8 +14,8 @@
<div class="toolbar-spacer" /> <div class="toolbar-spacer" />
<!-- Group: Quad view (pane toggles) --> <!-- Group: Quad view pane toggles (nyx only) -->
<div class="toolbar-group"> <div v-if="hasGroup('quad-view')" class="toolbar-group">
<button class="toolbar-btn" :class="{ active: isPaneOpen('chat') }" @click="togglePane('chat')" title="Chat"> <button class="toolbar-btn" :class="{ active: isPaneOpen('chat') }" @click="togglePane('chat')" title="Chat">
<ChatBubbleLeftIcon class="w-6 h-6" /> <ChatBubbleLeftIcon class="w-6 h-6" />
</button> </button>
@ -31,28 +31,18 @@
</div> </div>
<!-- Group: Themes --> <!-- Group: Themes -->
<div class="toolbar-group"> <div v-if="hasGroup('themes')" class="toolbar-group">
<button <button v-for="t in THEMES" :key="t" class="toolbar-btn" :class="{ active: theme === t }" @click="setTheme(t)" :title="THEME_NAMES[t]">
v-for="t in THEMES"
:key="t"
class="toolbar-btn"
:class="{ active: theme === t }"
@click="setTheme(t)"
:title="THEME_NAMES[t]"
>
<component :is="THEME_ICONS[t]" class="w-6 h-6" /> <component :is="THEME_ICONS[t]" class="w-6 h-6" />
</button> </button>
</div> </div>
<!-- Group: Panel config + version --> <!-- Group: Debug panels + version (nyx only) -->
<div class="toolbar-group"> <div v-if="hasGroup('panels')" class="toolbar-group">
<button <button
v-for="panel in availablePanels" v-for="panel in availablePanels" :key="panel.id"
:key="panel.id" class="toolbar-btn" :class="{ active: isPanelOpen(panel.id) }"
class="toolbar-btn" @click="togglePanelBtn(panel.id)" :title="panel.label"
:class="{ active: isPanelOpen(panel.id) }"
@click="togglePanelBtn(panel.id)"
:title="panel.label"
> >
<component :is="panelIcon(panel.id)" class="w-6 h-6" /> <component :is="panelIcon(panel.id)" class="w-6 h-6" />
</button> </button>
@ -66,10 +56,9 @@
<div v-if="openDropdown === 'conn'" class="toolbar-panel" style="right: auto; left: 0;"> <div v-if="openDropdown === 'conn'" class="toolbar-panel" style="right: auto; left: 0;">
<div class="toolbar-panel-header">Connection</div> <div class="toolbar-panel-header">Connection</div>
<div class="toolbar-panel-row"><span>HTTP</span><span>{{ chatStore.connectionState }}</span></div> <template v-for="(val, key) in connDetails" :key="key">
<div class="toolbar-panel-row"><span>Channel</span><span>{{ chatStore.channelState }}</span></div> <div class="toolbar-panel-row"><span>{{ key }}</span><span>{{ val }}</span></div>
<div class="toolbar-panel-row"><span>Agent</span><span>{{ selectedAgent || 'none' }}</span></div> </template>
<div class="toolbar-panel-row"><span>Mode</span><span>{{ selectedMode }}</span></div>
</div> </div>
<div v-if="openDropdown === 'takeover'" class="toolbar-panel" style="right: 0;"> <div v-if="openDropdown === 'takeover'" class="toolbar-panel" style="right: 0;">
@ -94,43 +83,34 @@ defineOptions({ name: 'AppToolbar' });
import { ref, computed, type Component } from 'vue'; import { ref, computed, type Component } from 'vue';
import { WifiIcon, SignalIcon, QueueListIcon, CpuChipIcon, EyeIcon, CircleStackIcon, ChatBubbleLeftIcon, RectangleGroupIcon, FolderIcon, SparklesIcon } from '@heroicons/vue/20/solid'; import { WifiIcon, SignalIcon, QueueListIcon, CpuChipIcon, EyeIcon, CircleStackIcon, ChatBubbleLeftIcon, RectangleGroupIcon, FolderIcon, SparklesIcon } from '@heroicons/vue/20/solid';
import { THEME_ICONS, THEME_NAMES, useTheme, type Theme } from '../composables/useTheme'; import { THEME_ICONS, THEME_NAMES, useTheme, type Theme } from '../composables/useTheme';
import { ws, auth, agents, takeover } from '../store'; import { ws, auth, takeover } from '../store';
import { useChatStore } from '../store/chat'; import { useChatStore } from '../store/chat';
import { useUI } from '../composables/ui'; import { useUI } from '../composables/ui';
import { usePanels, type PanelId, type PaneId } from '../composables/usePanels'; import { usePanels, type PanelId } from '../composables/usePanels';
import { injectToolbar } from '../composables/useToolbar';
const THEMES: Theme[] = ['loop42', 'titan', 'eras']; const THEMES: Theme[] = ['loop42', 'titan', 'eras'];
const { isLoggedIn } = auth; const { isLoggedIn } = auth;
const { selectedAgent, selectedMode } = agents;
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const chatStore = useChatStore(); const chatStore = useChatStore();
// Panels + panes
const { availablePanels, togglePanel: togglePanelBtn, isPanelOpen, togglePane, isPaneOpen } = usePanels(); const { availablePanels, togglePanel: togglePanelBtn, isPanelOpen, togglePane, isPaneOpen } = usePanels();
// Toolbar config injected from active view
const toolbar = injectToolbar();
const hasGroup = (g: string) => toolbar?.groups.includes(g as any) ?? false;
const conn = computed(() => toolbar?.connection);
const connActive = computed(() => conn.value?.connected.value ?? false);
const connLabel = computed(() => conn.value?.label.value ?? '');
const connDetails = computed(() => conn.value?.details?.() ?? {});
const PANEL_ICONS: Record<PanelId, Component> = { const PANEL_ICONS: Record<PanelId, Component> = {
graph: CircleStackIcon, graph: CircleStackIcon,
trace: QueueListIcon, trace: QueueListIcon,
nodes: CpuChipIcon, nodes: CpuChipIcon,
awareness: EyeIcon, awareness: EyeIcon,
}; };
function panelIcon(id: PanelId): Component { return PANEL_ICONS[id] || CpuChipIcon; }
function panelIcon(id: PanelId): Component {
return PANEL_ICONS[id] || CpuChipIcon;
}
// Connection
const connLabel = computed(() => {
switch (chatStore.connectionState) {
case 'CONNECTING': return 'Connecting...';
case 'LOADING_HISTORY': return 'Loading...';
case 'SWITCHING': return 'Switching...';
case 'SYNCED': return 'Connected';
default: return '';
}
});
const connActive = computed(() => chatStore.connectionState === 'SYNCED');
// Takeover // Takeover
const takeoverToken = takeover.token; const takeoverToken = takeover.token;
@ -138,20 +118,15 @@ const captureActive = takeover.capture.isActive;
const tokenCopied = ref(false); const tokenCopied = ref(false);
async function toggleCapture() { async function toggleCapture() {
if (captureActive.value) { if (captureActive.value) takeover.capture.disable();
takeover.capture.disable(); else await takeover.capture.enable();
} else {
await takeover.capture.enable();
} }
}
function copyToken() { function copyToken() {
if (!takeoverToken.value) return; if (!takeoverToken.value) return;
navigator.clipboard.writeText(takeoverToken.value); navigator.clipboard.writeText(takeoverToken.value);
tokenCopied.value = true; tokenCopied.value = true;
setTimeout(() => { tokenCopied.value = false; }, 1500); setTimeout(() => { tokenCopied.value = false; }, 1500);
} }
function revokeAndClose() { function revokeAndClose() {
takeover.revoke(); takeover.revoke();
openDropdown.value = null; openDropdown.value = null;
@ -162,18 +137,11 @@ const { version } = useUI(ws.status);
const versionShort = version.split('-')[0]; const versionShort = version.split('-')[0];
const envLabel = import.meta.env.PROD ? 'prod' : 'dev'; const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
const versionFull = computed(() => `${envLabel} | fe: ${version} | be: ${chatStore.beVersion || '...'}`); const versionFull = computed(() => `${envLabel} | fe: ${version} | be: ${chatStore.beVersion || '...'}`);
const versionCopied = ref(false);
function copyVersionDetails() { function copyVersionDetails() {
const details = `env: ${envLabel}\nfe: ${version}\nbe: ${chatStore.beVersion || 'unknown'}\nua: ${navigator.userAgent}`; navigator.clipboard.writeText(`env: ${envLabel}\nfe: ${version}\nbe: ${chatStore.beVersion || 'unknown'}\nua: ${navigator.userAgent}`);
navigator.clipboard.writeText(details);
versionCopied.value = true;
setTimeout(() => { versionCopied.value = false; }, 2000);
} }
// Toolbar dropdown (conn/takeover panels distinct from debug panel toggles)
const openDropdown = ref<'conn' | 'takeover' | null>(null); const openDropdown = ref<'conn' | 'takeover' | null>(null);
function toggleDropdown(panel: 'conn' | 'takeover') { function toggleDropdown(panel: 'conn' | 'takeover') {
openDropdown.value = openDropdown.value === panel ? null : panel; openDropdown.value = openDropdown.value === panel ? null : panel;
} }

View File

@ -0,0 +1,80 @@
/**
* Toolbar configuration views provide their toolbar needs, AppToolbar injects.
*
* Each view declares:
* - which groups to show (quad-view, themes, panels)
* - which connection to surface (optional)
*
* AppToolbar renders only what the active view declares.
* Views that don't provide toolbar shows nothing (login, home).
*/
import { inject, provide, onMounted, onUnmounted, ref, type InjectionKey, type Ref } from 'vue';
// --- Types ---
export interface ToolbarConnection {
connected: Ref<boolean>;
label: Ref<string>; // shown when not idle (e.g. "Connecting...")
details?: () => Record<string, string>; // for dropdown panel rows
}
export type ToolbarGroup = 'quad-view' | 'themes' | 'panels';
export interface ToolbarConfig {
connection?: ToolbarConnection;
groups: ToolbarGroup[];
}
// --- Injection key ---
const TOOLBAR_KEY: InjectionKey<ToolbarConfig> = Symbol('toolbar');
export function provideToolbar(config: ToolbarConfig) {
provide(TOOLBAR_KEY, config);
}
export function injectToolbar(): ToolbarConfig | undefined {
return inject(TOOLBAR_KEY, undefined);
}
// --- useConnection: manages an SSE endpoint, returns connection state ---
export function useConnection(
url: string,
onMessage?: (data: any) => void,
): ToolbarConnection {
const connected = ref(false);
const label = ref('Connecting...');
let es: EventSource | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
function connect() {
es?.close();
es = new EventSource(url);
es.onopen = () => {
connected.value = true;
label.value = '';
};
es.onerror = () => {
connected.value = false;
label.value = 'Disconnected';
es?.close();
es = null;
retryTimer = setTimeout(connect, 5000);
};
es.onmessage = (e) => {
if (!onMessage) return;
try { onMessage(JSON.parse(e.data)); } catch {}
};
}
onMounted(connect);
onUnmounted(() => {
if (retryTimer) clearTimeout(retryTimer);
es?.close();
es = null;
});
return { connected, label };
}

View File

@ -75,7 +75,9 @@ defineOptions({ name: 'AgentsView' });
import { ref, watch, computed } from 'vue'; import { ref, watch, computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { ws, auth, agents } from '../store'; import { ws, auth, agents } from '../store';
import { useChatStore } from '../store/chat';
import { usePanels } from '../composables/usePanels'; import { usePanels } from '../composables/usePanels';
import { provideToolbar } from '../composables/useToolbar';
import { LockClosedIcon, UserGroupIcon } from '@heroicons/vue/20/solid'; import { LockClosedIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import ChatPane from '../components/ChatPane.vue'; import ChatPane from '../components/ChatPane.vue';
import ContentLayout from '../components/ContentLayout.vue'; import ContentLayout from '../components/ContentLayout.vue';
@ -96,6 +98,29 @@ const { connected, send: wsSend } = ws;
const { isLoggedIn } = auth; const { isLoggedIn } = auth;
const { selectedAgent, selectedMode, filteredAgents, defaultAgent, allAgents } = agents; const { selectedAgent, selectedMode, filteredAgents, defaultAgent, allAgents } = agents;
// Toolbar wrap chatStore state into ToolbarConnection shape
const chatStore = useChatStore();
provideToolbar({
groups: ['quad-view', 'themes', 'panels'],
connection: {
connected: computed(() => chatStore.connectionState === 'SYNCED'),
label: computed(() => {
switch (chatStore.connectionState) {
case 'CONNECTING': return 'Connecting...';
case 'LOADING_HISTORY': return 'Loading...';
case 'SWITCHING': return 'Switching...';
default: return '';
}
}),
details: () => ({
HTTP: chatStore.connectionState,
Channel: chatStore.channelState,
Agent: selectedAgent.value || 'none',
Mode: selectedMode.value,
}),
},
});
// Agent picker disabled for now (auto-select default agent) // Agent picker disabled for now (auto-select default agent)
// TODO: re-enable when multi-agent UX is designed // TODO: re-enable when multi-agent UX is designed
const showPicker = computed(() => false); const showPicker = computed(() => false);

View File

@ -57,9 +57,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { getApiBase } from '../utils/apiBase'; import { getApiBase } from '../utils/apiBase';
import { relativeTime } from '../utils/relativeTime'; import { relativeTime } from '../utils/relativeTime';
import { provideToolbar, useConnection } from '../composables/useToolbar';
interface TestStats { interface TestStats {
runs: number; runs: number;
@ -87,7 +88,10 @@ const runId = ref('');
const startedAt = ref(''); const startedAt = ref('');
const updatedAt = ref(''); const updatedAt = ref('');
const expanded = ref<Set<string>>(new Set()); const expanded = ref<Set<string>>(new Set());
let eventSource: EventSource | null = null;
// SSE connection managed by useConnection, state surfaced in toolbar
const testConn = useConnection(`${getApiBase()}/api/test-results`, handleResult);
provideToolbar({ groups: ['themes'], connection: testConn });
const displayResults = computed(() => const displayResults = computed(() =>
@ -133,16 +137,13 @@ function handleResult(data: any) {
} }
onMounted(() => { onMounted(() => {
const base = getApiBase(); // Fetch latest snapshot on load (SSE delivers live updates via useConnection)
fetch(`${getApiBase()}/api/test-results/latest`)
// First fetch snapshot
fetch(`${base}/api/test-results/latest`)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.run_id) runId.value = data.run_id; if (data.run_id) runId.value = data.run_id;
if (data.results) { if (data.results) {
results.value = data.results; results.value = data.results;
// Extract timestamps from results
const timestamps = data.results.map((r: any) => r.ts).filter(Boolean).sort(); const timestamps = data.results.map((r: any) => r.ts).filter(Boolean).sort();
if (timestamps.length) { if (timestamps.length) {
startedAt.value = timestamps[0]; startedAt.value = timestamps[0];
@ -151,20 +152,7 @@ onMounted(() => {
} }
}) })
.catch(() => {}); .catch(() => {});
// Then connect SSE for live updates
eventSource = new EventSource(`${base}/api/test-results`);
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.run_id) handleResult(data);
} catch {}
};
}); });
onUnmounted(() => {
eventSource?.close();
eventSource = null;
}); });
</script> </script>

View File

@ -85,8 +85,10 @@ import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
import { scrollbarOptions } from '../composables/useScrollbar'; import { scrollbarOptions } from '../composables/useScrollbar';
import { FolderOpenIcon, FolderIcon, DocumentIcon, ArrowDownTrayIcon } from '@heroicons/vue/20/solid'; import { FolderOpenIcon, FolderIcon, DocumentIcon, ArrowDownTrayIcon } from '@heroicons/vue/20/solid';
import { useViewer } from '../composables/useViewer'; import { useViewer } from '../composables/useViewer';
import { provideToolbar } from '../composables/useToolbar';
import { computed } from 'vue'; import { computed } from 'vue';
provideToolbar({ groups: ['themes'] });
import { getApiBase } from '../utils/apiBase'; import { getApiBase } from '../utils/apiBase';
const { const {