This repository has been archived on 2026-04-03. You can view files and clone it, but cannot push or open issues or pull requests.
nyx/src/components/AppToolbar.vue
Nico 409d873aa0 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>
2026-04-03 22:52:28 +02:00

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>