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>
289 lines
9.9 KiB
Vue
289 lines
9.9 KiB
Vue
<template>
|
|
<div v-if="isLoggedIn && toolbar" class="app-toolbar">
|
|
|
|
<!-- Group: Connection + Takeover (only if view provides a connection) -->
|
|
<div v-if="conn" class="toolbar-group">
|
|
<button class="toolbar-btn" :class="{ active: connActive }" @click="toggleDropdown('conn')" :title="connLabel || 'Connection'">
|
|
<WifiIcon class="w-6 h-6" />
|
|
<span v-if="connLabel" class="toolbar-btn-label">{{ connLabel }}</span>
|
|
</button>
|
|
<button v-if="takeoverToken" class="toolbar-btn" :class="{ active: captureActive }" @click="toggleDropdown('takeover')" title="Takeover">
|
|
<SignalIcon class="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar-spacer" />
|
|
|
|
<!-- Group: Quad view — pane toggles (nyx only) -->
|
|
<div v-if="hasGroup('quad-view')" class="toolbar-group">
|
|
<button class="toolbar-btn" :class="{ active: isPaneOpen('chat') }" @click="togglePane('chat')" title="Chat">
|
|
<ChatBubbleLeftIcon class="w-6 h-6" />
|
|
</button>
|
|
<button class="toolbar-btn" :class="{ active: isPaneOpen('dashboard') }" @click="togglePane('dashboard')" title="Display">
|
|
<RectangleGroupIcon class="w-6 h-6" />
|
|
</button>
|
|
<button class="toolbar-btn" :class="{ active: isPaneOpen('files') }" @click="togglePane('files')" title="Files">
|
|
<FolderIcon class="w-6 h-6" />
|
|
</button>
|
|
<button class="toolbar-btn" :class="{ active: isPaneOpen('artifacts') }" @click="togglePane('artifacts')" title="Konsole">
|
|
<SparklesIcon class="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Group: Themes -->
|
|
<div v-if="hasGroup('themes')" class="toolbar-group">
|
|
<button 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" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Group: Debug panels + version (nyx only) -->
|
|
<div v-if="hasGroup('panels')" class="toolbar-group">
|
|
<button
|
|
v-for="panel in availablePanels" :key="panel.id"
|
|
class="toolbar-btn" :class="{ active: isPanelOpen(panel.id) }"
|
|
@click="togglePanelBtn(panel.id)" :title="panel.label"
|
|
>
|
|
<component :is="panelIcon(panel.id)" class="w-6 h-6" />
|
|
</button>
|
|
<button class="toolbar-btn toolbar-btn-dim" @click="copyVersionDetails" :title="versionFull">
|
|
<span class="toolbar-version-text">{{ versionShort }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Dropdowns -->
|
|
<div v-if="openDropdown" class="toolbar-panel-backdrop" @click="openDropdown = null" />
|
|
|
|
<div v-if="openDropdown === 'conn'" class="toolbar-panel" style="right: auto; left: 0;">
|
|
<div class="toolbar-panel-header">Connection</div>
|
|
<template v-for="(val, key) in connDetails" :key="key">
|
|
<div class="toolbar-panel-row"><span>{{ key }}</span><span>{{ val }}</span></div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="openDropdown === 'takeover'" class="toolbar-panel" style="right: 0;">
|
|
<div class="toolbar-panel-header">Takeover</div>
|
|
<div class="toolbar-panel-token" @click="copyToken" :title="tokenCopied ? 'Copied!' : 'Click to copy'">
|
|
<code>{{ takeoverToken }}</code>
|
|
<span v-if="tokenCopied" class="toolbar-panel-copied">Copied!</span>
|
|
</div>
|
|
<div class="toolbar-panel-row">
|
|
<span>Capture</span>
|
|
<span :style="{ color: captureActive ? 'var(--success, #22c55e)' : 'var(--text-dim)' }">{{ captureActive ? 'ON' : 'OFF' }}</span>
|
|
</div>
|
|
<button class="toolbar-panel-action" @click="toggleCapture">{{ captureActive ? 'Disable Capture' : 'Enable Capture' }}</button>
|
|
<button class="toolbar-panel-action" @click="revokeAndClose">Revoke</button>
|
|
</div>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
defineOptions({ name: 'AppToolbar' });
|
|
import { ref, computed, type Component } from 'vue';
|
|
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 { ws, auth, takeover } from '../store';
|
|
import { useChatStore } from '../store/chat';
|
|
import { useUI } from '../composables/ui';
|
|
import { usePanels, type PanelId } from '../composables/usePanels';
|
|
import { injectToolbar } from '../composables/useToolbar';
|
|
|
|
const THEMES: Theme[] = ['loop42', 'titan', 'eras'];
|
|
|
|
const { isLoggedIn } = auth;
|
|
const { theme, setTheme } = useTheme();
|
|
const chatStore = useChatStore();
|
|
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> = {
|
|
graph: CircleStackIcon,
|
|
trace: QueueListIcon,
|
|
nodes: CpuChipIcon,
|
|
awareness: EyeIcon,
|
|
};
|
|
function panelIcon(id: PanelId): Component { return PANEL_ICONS[id] || CpuChipIcon; }
|
|
|
|
// Takeover
|
|
const takeoverToken = takeover.token;
|
|
const captureActive = takeover.capture.isActive;
|
|
const tokenCopied = ref(false);
|
|
|
|
async function toggleCapture() {
|
|
if (captureActive.value) takeover.capture.disable();
|
|
else await takeover.capture.enable();
|
|
}
|
|
function copyToken() {
|
|
if (!takeoverToken.value) return;
|
|
navigator.clipboard.writeText(takeoverToken.value);
|
|
tokenCopied.value = true;
|
|
setTimeout(() => { tokenCopied.value = false; }, 1500);
|
|
}
|
|
function revokeAndClose() {
|
|
takeover.revoke();
|
|
openDropdown.value = null;
|
|
}
|
|
|
|
// Version
|
|
const { version } = useUI(ws.status);
|
|
const versionShort = version.split('-')[0];
|
|
const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
|
|
const versionFull = computed(() => `${envLabel} | fe: ${version} | be: ${chatStore.beVersion || '...'}`);
|
|
function copyVersionDetails() {
|
|
navigator.clipboard.writeText(`env: ${envLabel}\nfe: ${version}\nbe: ${chatStore.beVersion || 'unknown'}\nua: ${navigator.userAgent}`);
|
|
}
|
|
|
|
const openDropdown = ref<'conn' | 'takeover' | null>(null);
|
|
function toggleDropdown(panel: 'conn' | 'takeover') {
|
|
openDropdown.value = openDropdown.value === panel ? null : panel;
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.app-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--panel-gap, 6px);
|
|
padding: var(--panel-gap, 6px);
|
|
padding-bottom: 0;
|
|
flex-shrink: 0;
|
|
position: relative;
|
|
}
|
|
|
|
.toolbar-spacer { flex: 1; }
|
|
|
|
/* Group: shared pill container */
|
|
.toolbar-group {
|
|
display: flex;
|
|
align-items: center;
|
|
background: var(--panel-bg);
|
|
border-radius: var(--radius-panel, 12px);
|
|
box-shadow: var(--panel-shadow);
|
|
padding: 2px;
|
|
gap: 1px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Button inside a group */
|
|
.toolbar-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 5px;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: calc(var(--radius-panel, 12px) - 3px);
|
|
color: var(--text-dim);
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
white-space: nowrap;
|
|
transition: color 0.12s, background 0.12s;
|
|
flex-shrink: 0;
|
|
}
|
|
.toolbar-btn:hover { color: var(--text); background: color-mix(in srgb, var(--text) 6%, transparent); }
|
|
.toolbar-btn.active { color: var(--accent); }
|
|
|
|
/* Connection button widens when it has a label */
|
|
.toolbar-btn:has(.toolbar-btn-label) {
|
|
width: auto;
|
|
padding: 0 8px;
|
|
}
|
|
.toolbar-btn-label {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.toolbar-btn-dim { opacity: 0.45; }
|
|
.toolbar-btn-dim:hover { opacity: 0.8; }
|
|
|
|
.toolbar-version-text { font-size: 0.65rem; }
|
|
|
|
@media (max-width: 480px) {
|
|
.toolbar-btn-label { display: none; }
|
|
.toolbar-btn:has(.toolbar-btn-label) { width: 32px; padding: 0; }
|
|
}
|
|
|
|
/* Panel dropdown */
|
|
.toolbar-panel-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 150;
|
|
}
|
|
|
|
.toolbar-panel {
|
|
position: absolute;
|
|
top: 100%;
|
|
margin-top: 4px;
|
|
width: 220px;
|
|
background: var(--panel-bg);
|
|
border-radius: var(--radius-panel, 12px);
|
|
box-shadow: var(--panel-shadow);
|
|
z-index: 200;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.toolbar-panel-header {
|
|
padding: 8px 12px 4px;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.toolbar-panel-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 6px 12px;
|
|
font-size: 0.85rem;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.toolbar-panel-token {
|
|
padding: 8px 12px;
|
|
cursor: pointer;
|
|
transition: background 0.12s;
|
|
position: relative;
|
|
}
|
|
.toolbar-panel-token:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
|
.toolbar-panel-token code {
|
|
font-size: 0.85rem;
|
|
color: var(--accent);
|
|
word-break: break-all;
|
|
}
|
|
.toolbar-panel-copied {
|
|
position: absolute;
|
|
right: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
font-size: 0.85rem;
|
|
color: var(--success, #22c55e);
|
|
}
|
|
|
|
.toolbar-panel-action {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 7px 12px;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--text);
|
|
font-size: 0.85rem;
|
|
text-align: left;
|
|
transition: background 0.12s, color 0.12s;
|
|
}
|
|
.toolbar-panel-action:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--accent); }
|
|
|
|
@media (max-width: 480px) {
|
|
.toolbar-panel { width: min(220px, calc(100vw - 16px)); }
|
|
}
|
|
</style>
|