Production-ready sidebar, toolbar, auth, and routing for loop42.de

Slim sidebar (Home, nyx, Impressum, Datenschutz, Sign-in/User), global
AppToolbar for system features, /agents→/nyx rename, agent auto-select,
OIDC user name extraction from id_token, theme-consistent content pages,
removed DevView and old system panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nico 2026-04-01 16:14:24 +02:00
parent 4718feb773
commit db10ab93fd
17 changed files with 929 additions and 1338 deletions

View File

@ -3,7 +3,7 @@
:root { :root {
--sidebar-width: 240px; --sidebar-width: 240px;
--sidebar-collapsed-width: 48px; --sidebar-collapsed-width: 48px;
--sidebar-header-height: 40px; /* aligns with content-top in views */ --sidebar-header-height: 40px;
} }
/* ── App body: sidebar + content column ── */ /* ── App body: sidebar + content column ── */
@ -42,34 +42,20 @@
z-index: 100; z-index: 100;
} }
/* Fade text/indicators on expand/collapse */ /* Fade text on collapse */
.sidebar-logo-label, .sidebar-brand-name,
.sidebar-room-name,
.sidebar-segment-label,
.sidebar-channel-indicators,
.sidebar-room-mode-btn,
.sidebar-chevron-btn,
.sidebar-capture-btn,
.sidebar-link span, .sidebar-link span,
.sidebar-user-name { .sidebar-user-name {
opacity: 1; opacity: 1;
transition: opacity 0.15s ease 0.1s; /* 0.1s delay so width expands first */ transition: opacity 0.15s ease 0.1s;
} }
.app-sidebar.is-collapsed .sidebar-logo-label, .app-sidebar.is-collapsed .sidebar-brand-name,
.app-sidebar.is-collapsed .sidebar-room-name,
.app-sidebar.is-collapsed .sidebar-segment-label,
.app-sidebar.is-collapsed .sidebar-channel-indicators,
.app-sidebar.is-collapsed .sidebar-room-mode-btn,
.app-sidebar.is-collapsed .sidebar-chevron-btn,
.app-sidebar.is-collapsed .sidebar-capture-btn,
.app-sidebar.is-collapsed .sidebar-link span, .app-sidebar.is-collapsed .sidebar-link span,
.app-sidebar.is-collapsed .sidebar-user-name { .app-sidebar.is-collapsed .sidebar-user-name {
opacity: 0; opacity: 0;
transition: opacity 0.05s ease; /* fade out fast */ transition: opacity 0.05s ease;
pointer-events: none; pointer-events: none;
position: relative;
z-index: 20;
} }
.app-sidebar.is-collapsed { .app-sidebar.is-collapsed {
@ -81,12 +67,7 @@
pointer-events: auto; pointer-events: auto;
} }
/* Legacy gradient shadow — replaced by panel box-shadow */ /* Invisible click target to close sidebar */
.sidebar-shadow {
display: none;
}
/* Invisible click target to close sidebar — behind sidebar content */
.sidebar-close-target { .sidebar-close-target {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -95,19 +76,15 @@
pointer-events: none; pointer-events: none;
} }
/* ── Sidebar header (logo + collapse) ── */ /* ── Header (toggle + brand) ── */
.sidebar-header { .sidebar-header {
height: var(--sidebar-header-height); height: var(--sidebar-header-height);
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
gap: 0; overflow: hidden;
} }
.sidebar-header { overflow: hidden; }
.app-sidebar.is-collapsed .sidebar-header { cursor: pointer; }
/* Brand: logo + name centered (flex:1 fills remaining space) */
.sidebar-brand { .sidebar-brand {
flex: 1; flex: 1;
display: flex; display: flex;
@ -118,15 +95,13 @@
color: var(--text); color: var(--text);
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
transition: opacity 0.15s; transition: color 0.12s;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
/* Offset for chevron width so brand is visually centered in sidebar */ padding-right: 36px; /* offset toggle btn width so brand is visually centered */
padding-right: var(--sidebar-collapsed-width);
} }
.sidebar-brand:hover { opacity: 0.8; text-decoration: none; } .sidebar-brand:hover { color: var(--text); text-decoration: none; }
/* Chevron rotation animation */
.sidebar-chevron-anim { .sidebar-chevron-anim {
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
} }
@ -145,10 +120,9 @@
white-space: nowrap; white-space: nowrap;
} }
/* Toggle button: right side when expanded, centered when collapsed */
.sidebar-toggle-btn { .sidebar-toggle-btn {
width: var(--sidebar-collapsed-width); width: 36px;
height: 100%; height: 32px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
@ -157,194 +131,20 @@
border: none; border: none;
cursor: pointer; cursor: pointer;
color: var(--text-dim); color: var(--text-dim);
transition: color 0.15s, background 0.15s; border-radius: var(--radius-sm);
transition: color 0.12s, background 0.12s;
margin-left: 0;
} }
.sidebar-toggle-btn:hover { color: var(--text); background: color-mix(in srgb, var(--accent) 8%, transparent); } .sidebar-toggle-btn:hover { color: var(--text); background: color-mix(in srgb, var(--accent) 8%, transparent); }
/* Icon slot: fixed 48px centered area — never moves on collapse */ /* ── Nav links ── */
.sidebar-logo-img, .sidebar-nav {
.sidebar-theme-icon {
flex-shrink: 0;
width: var(--sidebar-collapsed-width);
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-logo-img {
height: 18px;
object-fit: contain;
padding: 0 14px;
box-sizing: border-box;
}
.sidebar-theme-icon {
height: 20px;
stroke-width: 1.5;
color: var(--accent);
padding: 0 14px;
box-sizing: border-box;
}
.sidebar-logo-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
letter-spacing: 0.02em;
}
.sidebar-chevron-icon {
margin-left: auto;
opacity: 0.5;
flex-shrink: 0;
}
.sidebar-chevron-btn {
width: var(--sidebar-collapsed-width);
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: var(--text-dim);
transition: color 0.15s, background 0.15s;
}
.sidebar-chevron-btn:hover { color: var(--text); background: color-mix(in srgb, var(--accent) 8%, transparent); }
/* ── Rooms (agents) ── */
.sidebar-rooms {
flex: 1 1 0;
min-height: 0;
padding: 4px 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px;
overflow-x: hidden;
overflow-y: auto;
}
/* Segment label */
.sidebar-segment-label {
padding: 8px 14px 2px;
height: 27px;
flex-shrink: 0;
font-size: var(--text-base);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: capitalize;
color: var(--text-dim);
opacity: 0.5;
user-select: none;
}
/* Agent row: icon slot is always 48px wide, centered */
.sidebar-room {
display: flex;
align-items: center;
gap: 0;
padding: 0 10px 0 0;
height: 30px;
min-height: 30px;
flex-shrink: 0;
color: var(--text-dim);
text-decoration: none;
font-size: var(--text-base);
white-space: nowrap;
overflow: hidden;
transition: background 0.12s, color 0.12s;
border-radius: var(--radius-sm);
cursor: pointer;
}
.sidebar-room:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--text); }
.sidebar-room.active { color: var(--text); background: color-mix(in srgb, var(--accent) 12%, transparent); }
/* ── Role dots ── */
.sidebar-room-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
position: relative;
/* Center dot in 48px icon slot (minus 6px panel padding when expanded) */
margin-left: 14px;
margin-right: 12px;
}
/* owner: solid filled dot */
.dot-owner {
background: var(--accent);
box-shadow: 0 0 0 0px transparent;
}
/* member: outlined ring (dot + same-color ring) */
.dot-member {
background: transparent;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 60%, transparent);
}
/* common: small dot + outer ring gap */
.dot-common {
background: #34d399;
box-shadow: 0 0 0 2px color-mix(in srgb, #34d399 40%, transparent);
}
/* guest: dashed ring (simulate with border) */
.dot-guest {
background: transparent;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-dim) 40%, transparent);
}
/* utility: dim solid */
.dot-utility {
background: color-mix(in srgb, var(--text-dim) 35%, transparent);
}
.sidebar-room-placeholder { pointer-events: none; opacity: 0; }
.sidebar-room-name {
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-room-mode-btn {
margin-left: auto;
color: var(--text-dim);
opacity: 0.55;
white-space: nowrap;
flex-shrink: 0;
text-decoration: none;
padding: 1px 6px;
border-radius: 4px;
cursor: pointer;
}
.sidebar-room-mode-btn:hover { opacity: 1; color: var(--text); background: color-mix(in srgb, var(--accent) 12%, transparent); }
.sidebar-room-mode-btn.active { opacity: 1; color: var(--accent); }
/* Collapsed: keep segment labels same height for Y alignment, just hide text */
.sidebar-segment-label.is-collapsed {
visibility: hidden;
}
/* ── Spacer ── */
.sidebar-spacer {
flex: 1;
}
/* ── Home section (above agents) ── */
.sidebar-home-section {
}
/* ── Nav links (viewer, dev) ── */
.sidebar-nav-links {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 0; padding: 4px 0;
overflow: hidden;
} }
/* Shared row style: icon in fixed 48px slot, text after */ /* Unified link style — used for all sidebar items */
.sidebar-link { .sidebar-link {
display: flex; display: flex;
align-items: center; align-items: center;
@ -362,131 +162,51 @@
border: none; border: none;
width: 100%; width: 100%;
text-align: left; text-align: left;
border-radius: var(--radius-sm);
} }
.sidebar-link:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--text); } .sidebar-link:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--text); }
.sidebar-link.active { color: var(--accent); } .sidebar-link.active { color: var(--accent); }
.sidebar-link svg { .sidebar-link svg {
flex-shrink: 0; flex-shrink: 0;
/* Center 16px icon in 48px slot (minus 6px panel padding) */
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-left: 10px; margin-left: 10px;
margin-right: 10px; margin-right: 10px;
} }
/* ── Spacer ── */
.sidebar-flex-spacer {
flex: 1;
}
/* ── Channel state indicators (dual: private + public) ── */ /* ── Footer (legal + user) ── */
.sidebar-room { position: relative; } .sidebar-footer {
.sidebar-channel-indicators {
position: absolute;
right: 44px;
top: 0;
display: flex; display: flex;
gap: 3px; flex-direction: column;
align-items: center;
height: 30px;
pointer-events: none;
font-size: var(--text-base);
}
.sidebar-ch-dot { opacity: 0.7; }
.sidebar-ch-dot.ch-running { animation: pulse 2s infinite; }
.sidebar-ch-dot.ch-ready { opacity: 0.3; }
.sidebar-ch-dot.ch-fresh { opacity: 0.6; }
.sidebar-ch-dot.ch-nosession { opacity: 0.15; }
.sidebar-ch-dot.ch-none { opacity: 0.15; }
/* ── Connection status ── */
.sidebar-connection {
padding: 0;
}
.sidebar-connection .sidebar-link { color: var(--text-dim); opacity: 0.4; }
.sidebar-connection.active .sidebar-link { color: var(--success, #22c55e); opacity: 0.7; }
/* ── Connection status panel ── */
.sidebar-conn-wrap {
position: relative;
}
/* ── Takeover status ── */
.sidebar-takeover-wrap {
padding: 0;
position: relative;
}
.sidebar-takeover-row {
display: flex;
align-items: center;
}
.sidebar-takeover-row .sidebar-link { flex: 1; }
.sidebar-takeover-wrap .sidebar-link { color: var(--text-dim); opacity: 0.4; }
.sidebar-takeover-wrap.active .sidebar-link { color: var(--success, #22c55e); opacity: 0.7; }
.sidebar-takeover-wrap.active .sidebar-link svg { animation: pulse 2s infinite; }
.sidebar-capture-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
border-radius: var(--radius-sm);
background: none;
border: none;
cursor: pointer;
color: var(--text-dim);
opacity: 0.5;
transition: opacity 0.15s, color 0.15s, background 0.15s;
}
.sidebar-capture-btn:hover { opacity: 1; background: color-mix(in srgb, var(--accent) 8%, transparent); }
.sidebar-capture-btn.active { color: var(--success, #22c55e); opacity: 1; }
.sidebar-capture-btn.active svg { animation: pulse 2s infinite; }
/* ── Bottom (user) ── */
.sidebar-bottom {
padding: 4px 0; padding: 4px 0;
position: relative; position: relative;
z-index: 160;
} }
/* Footer links without icons: indent text to match icon-based rows */
.sidebar-footer > a.sidebar-link:not(:has(svg)) {
padding-left: 36px;
}
.app-sidebar.is-collapsed .sidebar-footer > a.sidebar-link:not(:has(svg)) {
padding-left: 0;
}
/* ── User menu ── */
.sidebar-user-wrap { .sidebar-user-wrap {
position: relative; position: relative;
} }
.sidebar-user-btn {
display: flex;
align-items: center;
gap: 0;
padding: 0;
height: 32px;
width: 100%;
background: none;
border: none;
cursor: pointer;
color: var(--text-dim);
font-size: var(--text-base);
white-space: nowrap;
overflow: hidden;
transition: background 0.12s, color 0.12s;
text-align: left;
}
.sidebar-user-btn:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--text); }
.sidebar-user-btn svg {
flex-shrink: 0;
width: 16px;
height: 16px;
margin-left: 10px;
margin-right: 10px;
}
.sidebar-user-name { overflow: hidden; text-overflow: ellipsis; }
/* ── Panel backdrop: closes any open panel on click ── */
.sidebar-panel-backdrop { .sidebar-panel-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 150; z-index: 150;
} }
/* ── Shared popup panel style ── */
.sidebar-panel,
.sidebar-user-menu { .sidebar-user-menu {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@ -496,57 +216,9 @@
background: var(--panel-bg); background: var(--panel-bg);
border-radius: var(--radius-panel); border-radius: var(--radius-panel);
box-shadow: var(--panel-shadow); box-shadow: var(--panel-shadow);
z-index: 200; /* above .sidebar-panel-backdrop (150) */ z-index: 200;
overflow: hidden; overflow: hidden;
} }
.sidebar-panel-header {
padding: 8px 12px 4px;
font-size: var(--text-base);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: capitalize;
color: var(--text-dim);
}
.sidebar-panel-token {
padding: 8px 12px;
cursor: pointer;
transition: background 0.12s;
position: relative;
}
.sidebar-panel-token:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.sidebar-panel-token code {
font-size: var(--text-base);
color: var(--accent);
word-break: break-all;
}
.sidebar-panel-copied {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
font-size: var(--text-base);
color: var(--success, #22c55e);
}
.sidebar-panel-row {
display: flex;
justify-content: space-between;
padding: 6px 12px;
font-size: var(--text-base);
color: var(--text-dim);
}
.sidebar-panel-item {
display: block;
width: 100%;
padding: 7px 12px;
background: none;
border: none;
cursor: pointer;
color: var(--text);
font-size: var(--text-base);
text-align: left;
transition: background 0.12s;
}
.sidebar-panel-item:hover { background: color-mix(in srgb, var(--error) 12%, transparent); color: var(--error); }
.sidebar-user-menu-header { .sidebar-user-menu-header {
padding: 8px 12px 4px; padding: 8px 12px 4px;
font-size: var(--text-base); font-size: var(--text-base);
@ -562,16 +234,11 @@
color: var(--text); color: var(--text);
font-size: var(--text-base); font-size: var(--text-base);
text-align: left; text-align: left;
transition: background 0.12s; transition: background 0.12s, color 0.12s;
} }
.sidebar-user-menu-item:hover { background: color-mix(in srgb, var(--error) 12%, transparent); color: var(--error); } .sidebar-user-menu-item:hover { background: color-mix(in srgb, var(--error) 12%, transparent); color: var(--error); }
/* Collapsed: smaller menu width */ /* ── Sidebar spacer (reserves rail width) ── */
.app-sidebar.is-collapsed .sidebar-user-menu {
width: 160px;
}
/* ── Sidebar spacer (reserves rail width on all screens) ── */
.sidebar-spacer { .sidebar-spacer {
display: block; display: block;
width: var(--sidebar-collapsed-width); width: var(--sidebar-collapsed-width);
@ -580,36 +247,7 @@
flex: 0 0 var(--sidebar-collapsed-width); flex: 0 0 var(--sidebar-collapsed-width);
} }
/* ── Version ── */ /* ── Large screens: sidebar in flow ── */
.sidebar-version-wrap {
position: relative;
flex-shrink: 0;
}
.sidebar-version-wrap.is-hidden {
visibility: hidden;
pointer-events: none;
}
.sidebar-version {
display: block;
width: 100%;
padding: 4px 12px;
text-align: left;
font-size: var(--text-base);
color: var(--text-dim);
opacity: 0.4;
cursor: pointer;
user-select: none;
background: none;
border: none;
transition: opacity 0.15s;
}
.sidebar-version:hover { opacity: 0.8; }
.sidebar-version-panel {
bottom: 100%;
margin-bottom: 4px;
}
/* ── Large screens: sidebar in flow, pushes content ── */
@media (min-width: 1024px) { @media (min-width: 1024px) {
.app-sidebar { .app-sidebar {
position: relative; position: relative;
@ -623,163 +261,14 @@
.app-sidebar:not(.is-collapsed) .sidebar-close-target { .app-sidebar:not(.is-collapsed) .sidebar-close-target {
pointer-events: none; pointer-events: none;
} }
/* Spacer matches sidebar width (not fixed 48px) */
.sidebar-spacer { .sidebar-spacer {
display: none; display: none;
} }
} }
/* ── Mobile tweaks ── */ /* ── Mobile ── */
@media (max-width: 480px) { @media (max-width: 480px) {
/* Panels: constrain to viewport */
.sidebar-panel,
.sidebar-user-menu { .sidebar-user-menu {
width: min(220px, calc(100vw - var(--sidebar-collapsed-width) - 16px)) !important; width: min(220px, calc(100vw - var(--sidebar-collapsed-width) - 16px)) !important;
} }
/* Hide takeover panel on mobile — use /dev on desktop */
.sidebar-takeover-wrap .sidebar-panel { display: none !important; }
} }
/* Top section: default agent + files — shrink-to-fit, shrink when needed */
.sidebar-top-section {
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.sidebar-top-section.has-tree {
flex-shrink: 1;
overflow: hidden;
}
.sidebar-top-section .sidebar-home {
flex-shrink: 0;
padding: 4px 0;
}
.sidebar-file-scroll {
min-height: 0;
font-size: var(--text-base);
overflow: hidden;
}
/* File sections: toggle row + collapsible tree */
.sidebar-file-section {
display: flex;
flex-direction: column;
min-height: 0;
flex-shrink: 0;
}
.sidebar-file-section.is-open {
flex-shrink: 1;
min-height: 0;
overflow: hidden;
}
.sidebar-file-toggle {
flex-shrink: 0;
}
.sidebar-file-chev {
margin-left: auto;
color: var(--text-dim);
opacity: 0.3;
flex-shrink: 0;
margin-right: 10px;
}
.sidebar-file-toggle:hover .sidebar-file-chev { opacity: 1; }
/* Agents nav link */
.sidebar-nav-agents {
flex-shrink: 0;
padding: 4px 0;
}
/* File tree in sidebar — inherit sidebar font */
.sidebar-panel-section .file-tree {
font-size: var(--text-base);
font-family: inherit;
}
/* Segment divider inside agents panel */
.sidebar-segment-divider {
font-size: var(--text-base);
text-transform: capitalize;
letter-spacing: 0.05em;
color: var(--text-dim);
opacity: 0.5;
padding: 6px 14px 2px;
}
/* ── Collapsed top ── */
.sidebar-collapsed-top {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 0;
}
/* ── Flex spacer (both states) ── */
.sidebar-flex-spacer {
flex: 1;
}
/* ── Unified bottom section ── */
.sidebar-bottom-section {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 0;
position: relative;
z-index: 160; /* above backdrop (150) so buttons remain clickable */
}
.sidebar-bottom {
position: relative;
z-index: 160;
}
.sidebar-bottom-section .sidebar-conn-link.active { color: var(--success, #22c55e); }
.sidebar-bottom-section .sidebar-takeover-wrap.active .sidebar-link { color: var(--success, #22c55e); }
.sidebar-bottom-section .sidebar-takeover-wrap.active .sidebar-link svg { animation: pulse 2s infinite; }
.sidebar-version-link {
color: var(--text-dim) !important;
opacity: 0.5;
}
.sidebar-version-link:hover { opacity: 0.8 !important; }
.sidebar-version-text {
font-size: 0.65rem;
white-space: nowrap;
width: var(--sidebar-collapsed-width);
text-align: center;
/* Don't fade on collapse — it's the only content */
opacity: 1 !important;
pointer-events: auto;
}
/* SidebarPanel and system section above backdrop */
.sidebar-panel-section { position: relative; z-index: 160; }
.sidebar-system-section { position: relative; z-index: 160; overflow: visible; }
.sidebar-system-content { overflow: visible; }
.sidebar-system-toggle { opacity: 0.5; }
.sidebar-system-toggle:hover { opacity: 0.8; }
/* Clean up inside SidebarPanel */
.sidebar-panel-section .sidebar-nav-links { padding: 0; }
/* Compact bottom items: same size expanded and collapsed */
.sidebar-bottom-section .sidebar-link,
.sidebar-panel-content .sidebar-conn-link,
.sidebar-panel-content .sidebar-takeover-wrap .sidebar-link,
.sidebar-panel-content .sidebar-version-link {
height: 30px;
}
.sidebar-panel-content .sidebar-conn-link svg,
.sidebar-panel-content .sidebar-takeover-wrap .sidebar-link svg {
width: 14px;
height: 14px;
margin-left: 11px;
margin-right: 10px;
}
.sidebar-bottom-section .sidebar-link svg {
width: 14px;
height: 14px;
margin-left: 11px;
margin-right: 10px;
}

View File

@ -9,14 +9,16 @@
<div class="sidebar-spacer" /> <div class="sidebar-spacer" />
<div class="main-column"> <div class="main-column">
<AppToolbar />
<div class="content-area"> <div class="content-area">
<TtsPlayerBar /> <TtsPlayerBar />
<!-- Socket views: v-if=visited (lazy first mount), class-based hide (preserve scroll) --> <!-- Socket views: v-if=visited (lazy first mount), class-based hide (preserve scroll) -->
<AgentsView v-if="visited.agents" :class="{ 'view-hidden': route.name !== 'agents' }" /> <AgentsView v-if="visited.nyx" :class="{ 'view-hidden': route.name !== 'nyx' }" />
<ViewerView v-if="visited.viewer" :class="{ 'view-hidden': route.name !== 'viewer' }" /> <ViewerView v-if="visited.viewer" :class="{ 'view-hidden': route.name !== 'viewer' }" />
<DevView v-if="visited.dev" :class="{ 'view-hidden': route.name !== 'dev' }" /> <!-- Non-socket views: scrollable -->
<!-- Non-socket views: normal router --> <div v-if="routerReady && !isSocketRoute" class="page-scroll">
<RouterView v-if="routerReady && !isSocketRoute" /> <RouterView />
</div>
</div> </div>
<GridOverlay /> <GridOverlay />
<BreakpointBadge /> <BreakpointBadge />
@ -28,7 +30,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, computed, watch } from 'vue'; import { ref, reactive, onMounted, computed, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useUI } from './composables/ui';
import { ws, auth, agents } from './store'; import { ws, auth, agents } from './store';
import { THEME_LOGOS, useTheme } from './composables/useTheme'; import { THEME_LOGOS, useTheme } from './composables/useTheme';
import { useChatStore } from './store/chat'; import { useChatStore } from './store/chat';
@ -38,16 +39,16 @@ import { defineAsyncComponent } from 'vue';
import GridOverlay from './components/GridOverlay.vue'; import GridOverlay from './components/GridOverlay.vue';
import BreakpointBadge from './components/BreakpointBadge.vue'; import BreakpointBadge from './components/BreakpointBadge.vue';
import AppSidebar from './components/AppSidebar.vue'; import AppSidebar from './components/AppSidebar.vue';
import AppToolbar from './components/AppToolbar.vue';
import router from './router'; import router from './router';
import TtsPlayerBar from './components/TtsPlayerBar.vue'; import TtsPlayerBar from './components/TtsPlayerBar.vue';
const AgentsView = defineAsyncComponent(() => import('./views/AgentsView.vue')); const AgentsView = defineAsyncComponent(() => import('./views/AgentsView.vue'));
const ViewerView = defineAsyncComponent(() => import('./views/ViewerView.vue')); const ViewerView = defineAsyncComponent(() => import('./views/ViewerView.vue'));
const DevView = defineAsyncComponent(() => import('./views/DevView.vue'));
const chatStore = useChatStore(); const chatStore = useChatStore();
const visited = reactive({ agents: false, viewer: false, dev: false }); const visited = reactive({ nyx: false, viewer: false });
const isSocketRoute = computed(() => ['agents', 'viewer', 'dev'].includes(route.name as string)); const isSocketRoute = computed(() => ['nyx', 'viewer'].includes(route.name as string));
const routerReady = ref(false); const routerReady = ref(false);
router.isReady().then(() => { routerReady.value = true; }); router.isReady().then(() => { routerReady.value = true; });
@ -55,7 +56,6 @@ router.isReady().then(() => { routerReady.value = true; });
const route = useRoute(); const route = useRoute();
const { version } = useUI(ws.status);
const { isLoggedIn, doLogout } = auth; const { isLoggedIn, doLogout } = auth;
const { currentUser, connected, status, sessionId, disconnect, onMessage: onWsMessage, replayBuffer } = ws; const { currentUser, connected, status, sessionId, disconnect, onMessage: onWsMessage, replayBuffer } = ws;
const { selectedAgent, updateFromServer } = agents; const { selectedAgent, updateFromServer } = agents;
@ -64,7 +64,7 @@ const { theme } = useTheme();
function handleLogout() { function handleLogout() {
doLogout(disconnect); doLogout(disconnect);
visited.agents = visited.viewer = visited.dev = false; visited.nyx = visited.viewer = false;
} }
function maybeConnect() { function maybeConnect() {
@ -119,29 +119,6 @@ watch(theme, (t) => {
if (el) el.href = logo ?? '/favicon.ico'; if (el) el.href = logo ?? '/favicon.ico';
}); });
const beVersion = ref('');
const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
const versionState = ref<'short' | 'full' | 'copied'>('short');
const fullVersionText = computed(() => `[${envLabel}] fe: ${version} | be: ${beVersion.value || '…'}`);
const versionDisplay = computed(() => {
if (versionState.value === 'short') return version.split('-')[0];
if (versionState.value === 'copied') return '✓ copied';
return fullVersionText.value;
});
function cycleVersion() {
if (versionState.value === 'short') {
versionState.value = 'full';
} else if (versionState.value === 'full') {
navigator.clipboard.writeText(fullVersionText.value).then(() => {
versionState.value = 'copied';
setTimeout(() => versionState.value = 'short', 2000);
});
} else {
versionState.value = 'short';
}
}
onMounted(() => { onMounted(() => {
onWsMessage((data: any) => { onWsMessage((data: any) => {
if (data.type === 'ready' || data.type === 'auth_ok') { if (data.type === 'ready' || data.type === 'auth_ok') {
@ -149,9 +126,9 @@ onMounted(() => {
currentUser.value = data.user; currentUser.value = data.user;
sessionId.value = data.sessionId; sessionId.value = data.sessionId;
status.value = 'Connected'; status.value = 'Connected';
if (data.version) { beVersion.value = data.version; chatStore.beVersion = data.version; } if (data.version) { chatStore.beVersion = data.version; }
updateFromServer(data); updateFromServer(data);
if (route.path === '/login') router.push('/agents'); if (route.path === '/login') router.push('/nyx');
} else if (data.type === 'cost_update') { } else if (data.type === 'cost_update') {
chatStore.sessionUsage = data.usage; chatStore.sessionUsage = data.usage;
chatStore.sessionCost = data.cost; chatStore.sessionCost = data.cost;
@ -201,4 +178,5 @@ onMounted(() => {
overflow: hidden; overflow: hidden;
} }
.content-area { position: relative; } .content-area { position: relative; }
.page-scroll { flex: 1; overflow-y: auto; min-height: 0; }
</style> </style>

View File

@ -3,14 +3,12 @@
class="app-sidebar" class="app-sidebar"
:class="{ 'is-collapsed': !isOpen }" :class="{ 'is-collapsed': !isOpen }"
> >
<!-- Gradient shadow next to expanded sidebar -->
<div class="sidebar-shadow" />
<!-- Invisible full-screen click target to close --> <!-- Invisible full-screen click target to close -->
<div class="sidebar-close-target" @click="collapse" /> <div class="sidebar-close-target" @click="collapse" />
<!-- Top: [chevron] [logo + brand] --> <!-- Top: [chevron] [logo + brand] -->
<div class="sidebar-header"> <div class="sidebar-header">
<button class="sidebar-toggle-btn" @click="toggle" :title="isOpen ? 'Collapse' : 'Expand'"> <button class="sidebar-toggle-btn" @click="toggle">
<ChevronLeftIcon v-if="isOpen" class="sidebar-chevron-anim w-4 h-4" /> <ChevronLeftIcon v-if="isOpen" class="sidebar-chevron-anim w-4 h-4" />
<ChevronRightIcon v-else class="sidebar-chevron-anim w-4 h-4" /> <ChevronRightIcon v-else class="sidebar-chevron-anim w-4 h-4" />
</button> </button>
@ -21,232 +19,43 @@
</RouterLink> </RouterLink>
</div> </div>
<!-- Top section: default agent + agents link + files (grows to fill space) --> <!-- Nav -->
<div v-if="isLoggedIn && isOpen" class="sidebar-top-section" :class="{ 'has-tree': sharedOpen || workspaceOpen }"> <div class="sidebar-nav">
<!-- Default agent (placeholder keeps layout stable before WS config) --> <RouterLink to="/" class="sidebar-link" :class="{ active: route.name === 'home' }" @click="collapse">
<div class="sidebar-home"> <HomeIcon class="w-4 h-4" />
<div <span>Home</span>
v-if="homeAgent" </RouterLink>
class="sidebar-room" <button v-if="isLoggedIn" class="sidebar-link" :class="{ active: route.name === 'nyx' }" @click="goNyx">
:class="[`role-${homeAgent.role}`, { active: selectedAgent === homeAgent.id }]"
@click="handleModeClick(homeAgent.id, defaultMode(homeAgent))"
:title="'Chat with ' + homeAgent.name"
>
<span class="sidebar-room-dot" :class="`dot-${homeAgent.role}`"></span>
<span class="sidebar-room-name">{{ homeAgent.name }}</span>
</div>
<div v-else class="sidebar-room sidebar-room-placeholder">
<span class="sidebar-room-dot"></span>
</div>
</div>
<!-- Agents nav link -->
<button class="sidebar-link" :class="{ active: route.name === 'agents' && !route.query.agent }" @click="goAgentsOverview">
<ChatBubbleLeftRightIcon class="w-4 h-4" /> <ChatBubbleLeftRightIcon class="w-4 h-4" />
<span>Agents</span> <span>nyx</span>
</button> </button>
<!-- Shared files -->
<div v-if="viewerToken" class="sidebar-file-section" :class="{ 'is-open': sharedOpen }">
<button class="sidebar-link sidebar-file-toggle" :class="{ active: sharedOpen }" @click="toggleFileSection('shared')">
<FolderIcon class="w-4 h-4" />
<span>Shared</span>
</button>
<OverlayScrollbarsComponent v-if="sharedOpen" class="sidebar-file-scroll" :options="scrollbarOptions" element="div">
<FileTree
:token="viewerToken"
:active-path="lastViewerPath"
:expand-to="lastViewerPath"
:roots="['shared']"
:hide-root="true"
:folders-only="true"
@select="openInViewer"
/>
</OverlayScrollbarsComponent>
</div> </div>
<!-- Workspace files --> <!-- Spacer -->
<div v-if="viewerToken" class="sidebar-file-section" :class="{ 'is-open': workspaceOpen }"> <div class="sidebar-flex-spacer" />
<button class="sidebar-link sidebar-file-toggle" :class="{ active: workspaceOpen }" @click="toggleFileSection('workspace')">
<FolderOpenIcon class="w-4 h-4" /> <!-- Panel backdrop -->
<span>Workspace</span> <div v-if="userMenuOpen" class="sidebar-panel-backdrop" @click="userMenuOpen = false" />
</button>
<OverlayScrollbarsComponent v-if="workspaceOpen" class="sidebar-file-scroll" :options="scrollbarOptions" element="div"> <!-- Footer: legal + user (always visible) -->
<FileTree <div class="sidebar-footer">
:token="viewerToken" <RouterLink to="/impressum" class="sidebar-link" :class="{ active: route.name === 'impressum' }" @click="collapse">
:active-path="lastViewerPath" <DocumentTextIcon class="w-4 h-4" />
:expand-to="lastViewerPath" <span>Impressum</span>
:roots="[viewerWorkspaceRoot]"
:hide-root="true"
:folders-only="true"
@select="openInViewer"
/>
</OverlayScrollbarsComponent>
</div>
</div>
<!-- Collapsed: top icons -->
<div v-if="isLoggedIn && !isOpen" class="sidebar-collapsed-top">
<div
v-if="homeAgent"
class="sidebar-room"
:class="[`role-${homeAgent.role}`, { active: selectedAgent === homeAgent.id }]"
@click="handleModeClick(homeAgent.id, defaultMode(homeAgent))"
:title="homeAgent.name"
>
<span class="sidebar-room-dot" :class="`dot-${homeAgent.role}`"></span>
</div>
<button class="sidebar-link" :class="{ active: route.name === 'agents' && !route.query.agent }" title="Agents" @click="goAgentsOverview">
<ChatBubbleLeftRightIcon class="w-4 h-4" />
</button>
<RouterLink :to="{ name: 'viewer', query: { path: 'shared' } }" class="sidebar-link" :class="{ active: route.name === 'viewer' && lastViewerPath.startsWith('shared') }" title="Shared files">
<FolderIcon class="w-4 h-4" />
</RouterLink> </RouterLink>
<RouterLink :to="{ name: 'viewer', query: { path: viewerWorkspaceRoot } }" class="sidebar-link" :class="{ active: route.name === 'viewer' && lastViewerPath.startsWith('workspace') }" title="Workspace files"> <RouterLink to="/datenschutz" class="sidebar-link" :class="{ active: route.name === 'datenschutz' }" @click="collapse">
<FolderOpenIcon class="w-4 h-4" /> <ShieldCheckIcon class="w-4 h-4" />
<span>Datenschutz</span>
</RouterLink> </RouterLink>
</div>
<!-- Spacer: push bottom section down -->
<div v-if="isLoggedIn" class="sidebar-flex-spacer" />
<!-- Panel backdrop: click outside to close any open panel -->
<div v-if="anyPanelOpen" class="sidebar-panel-backdrop" @click="closeAllPanels" />
<!-- Expanded: collapsible System section (no OverlayScrollbars panels must escape overflow) -->
<div v-if="isLoggedIn && isOpen" class="sidebar-system-section" :class="{ collapsed: !systemOpen }">
<button class="sidebar-link sidebar-system-toggle" @click="systemOpen = !systemOpen; sessionStorage.setItem('sidebar_panel_system', String(systemOpen))">
<ChevronDownIcon v-if="systemOpen" class="w-4 h-4" />
<ChevronRightIcon v-else class="w-4 h-4" />
<span>System</span>
</button>
<div v-if="systemOpen" class="sidebar-system-content">
<!-- Connection -->
<div class="sidebar-conn-wrap">
<button class="sidebar-link sidebar-conn-link" :class="{ active: connActive }" @click="toggleConnPanel">
<WifiIcon class="w-4 h-4" />
<span>{{ connLabel }}</span>
</button>
<div v-if="connPanelOpen" class="sidebar-panel">
<div class="sidebar-panel-header">Connection</div>
<div class="sidebar-panel-row"><span>WebSocket</span><span>{{ chatStore.connectionState }}</span></div>
<div class="sidebar-panel-row"><span>Channel</span><span>{{ chatStore.channelState }}</span></div>
<div class="sidebar-panel-row"><span>Agent</span><span>{{ selectedAgent || 'none' }}</span></div>
<div class="sidebar-panel-row"><span>Mode</span><span>{{ selectedMode }}</span></div>
</div>
</div>
<!-- Takeover -->
<div class="sidebar-takeover-wrap" :class="{ active: !!takeoverToken }">
<button class="sidebar-link" :class="{ active: !!takeoverToken }" @click="toggleTakeoverPanel">
<SignalIcon class="w-4 h-4" />
<span>Takeover</span>
</button>
<div v-if="takeoverPanelOpen && takeoverToken" class="sidebar-panel">
<div class="sidebar-panel-header">Takeover Token</div>
<div class="sidebar-panel-token" @click="copyToken" :title="tokenCopied ? 'Copied!' : 'Click to copy'">
<code>{{ takeoverToken }}</code>
<span v-if="tokenCopied" class="sidebar-panel-copied">Copied!</span>
</div>
<div class="sidebar-panel-row">
<span>Capture</span>
<span :style="{ color: captureActive ? 'var(--success, #22c55e)' : 'var(--text-dim)' }">{{ captureActive ? 'ON' : 'OFF' }}</span>
</div>
<button class="sidebar-panel-item" @click="toggleCapture">
{{ captureActive ? 'Disable Capture' : 'Enable Capture' }}
</button>
<button class="sidebar-panel-item" @click="revokeAndClose">Revoke</button>
</div>
</div>
<!-- Dev -->
<RouterLink to="/dev" class="sidebar-link" :class="{ active: route.name === 'dev' }" @click="collapse">
<CodeBracketIcon class="w-4 h-4" />
<span>dev</span>
</RouterLink>
<!-- Version -->
<div class="sidebar-version-wrap">
<button class="sidebar-link sidebar-version-link" @click="toggleVersionPanel">
<span class="sidebar-version-text">{{ versionShort }}</span>
</button>
<div v-if="versionPanelOpen" class="sidebar-panel sidebar-version-panel">
<div class="sidebar-panel-header">Version</div>
<div class="sidebar-panel-row"><span>Frontend</span><span>{{ version }}</span></div>
<div class="sidebar-panel-row"><span>Backend</span><span>{{ chatStore.beVersion || '...' }}</span></div>
<div class="sidebar-panel-row"><span>Env</span><span>{{ envLabel }}</span></div>
<button class="sidebar-panel-item" @click="copyVersionDetails">
{{ versionCopied ? '✓ Copied' : 'Copy details' }}
</button>
</div>
</div>
</div>
</div>
<!-- Collapsed: bottom icons -->
<div v-if="isLoggedIn && !isOpen" class="sidebar-bottom-section">
<div class="sidebar-conn-wrap">
<button class="sidebar-link sidebar-conn-link" :class="{ active: connActive }" @click="toggleConnPanel" :title="connLabel">
<WifiIcon class="w-4 h-4" />
</button>
<div v-if="connPanelOpen" class="sidebar-panel">
<div class="sidebar-panel-header">Connection</div>
<div class="sidebar-panel-row"><span>WebSocket</span><span>{{ chatStore.connectionState }}</span></div>
<div class="sidebar-panel-row"><span>Channel</span><span>{{ chatStore.channelState }}</span></div>
<div class="sidebar-panel-row"><span>Agent</span><span>{{ selectedAgent || 'none' }}</span></div>
<div class="sidebar-panel-row"><span>Mode</span><span>{{ selectedMode }}</span></div>
</div>
</div>
<div class="sidebar-takeover-wrap" :class="{ active: !!takeoverToken }">
<button class="sidebar-link" :class="{ active: !!takeoverToken }" @click="toggleTakeoverPanel" title="Takeover">
<SignalIcon class="w-4 h-4" />
</button>
<div v-if="takeoverPanelOpen && takeoverToken" class="sidebar-panel">
<div class="sidebar-panel-header">Takeover Token</div>
<div class="sidebar-panel-token" @click="copyToken" :title="tokenCopied ? 'Copied!' : 'Click to copy'">
<code>{{ takeoverToken }}</code>
<span v-if="tokenCopied" class="sidebar-panel-copied">Copied!</span>
</div>
<div class="sidebar-panel-row">
<span>Capture</span>
<span :style="{ color: captureActive ? 'var(--success, #22c55e)' : 'var(--text-dim)' }">{{ captureActive ? 'ON' : 'OFF' }}</span>
</div>
<button class="sidebar-panel-item" @click="toggleCapture">
{{ captureActive ? 'Disable Capture' : 'Enable Capture' }}
</button>
<button class="sidebar-panel-item" @click="revokeAndClose">Revoke</button>
</div>
</div>
<RouterLink to="/dev" class="sidebar-link" :class="{ active: route.name === 'dev' }" title="Dev" @click="collapse">
<CodeBracketIcon class="w-4 h-4" />
</RouterLink>
<div class="sidebar-version-wrap">
<button class="sidebar-link sidebar-version-link" @click="toggleVersionPanel" :title="versionShort">
<span class="sidebar-version-text">{{ versionShort }}</span>
</button>
<div v-if="versionPanelOpen" class="sidebar-panel sidebar-version-panel">
<div class="sidebar-panel-header">Version</div>
<div class="sidebar-panel-row"><span>Frontend</span><span>{{ version }}</span></div>
<div class="sidebar-panel-row"><span>Backend</span><span>{{ chatStore.beVersion || '...' }}</span></div>
<div class="sidebar-panel-row"><span>Env</span><span>{{ envLabel }}</span></div>
<button class="sidebar-panel-item" @click="copyVersionDetails">
{{ versionCopied ? '✓ Copied' : 'Copy details' }}
</button>
</div>
</div>
</div>
<!-- User (always unified) -->
<div class="sidebar-bottom">
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
<div class="sidebar-user-wrap" :class="{ open: userMenuOpen }"> <div class="sidebar-user-wrap" :class="{ open: userMenuOpen }">
<button <button
class="sidebar-user-btn" class="sidebar-link"
@click="toggleUserMenu" @click="toggleUserMenu"
:title="isOpen ? '' : currentUser" :title="isOpen ? '' : currentUser"
> >
<UserCircleIcon class="w-4 h-4" /> <UserCircleIcon class="w-4 h-4" />
<span v-if="isOpen" class="sidebar-user-name">{{ currentUser }}</span> <span>{{ currentUser || 'Nico' }}</span>
</button> </button>
<div v-if="userMenuOpen" class="sidebar-user-menu"> <div v-if="userMenuOpen" class="sidebar-user-menu">
<div class="sidebar-user-menu-header">{{ currentUser }}</div> <div class="sidebar-user-menu-header">{{ currentUser }}</div>
@ -256,7 +65,7 @@
</template> </template>
<RouterLink v-else to="/login" class="sidebar-link" :title="isOpen ? '' : 'Sign in'"> <RouterLink v-else to="/login" class="sidebar-link" :title="isOpen ? '' : 'Sign in'">
<ArrowRightEndOnRectangleIcon class="w-4 h-4" /> <ArrowRightEndOnRectangleIcon class="w-4 h-4" />
<span v-if="isOpen">Sign in</span> <span>Sign in</span>
</RouterLink> </RouterLink>
</div> </div>
</aside> </aside>
@ -267,178 +76,17 @@ defineOptions({ name: 'AppSidebar' });
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { import {
ChevronDownIcon,
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
FolderIcon,
FolderOpenIcon,
CodeBracketIcon,
UserCircleIcon, UserCircleIcon,
ArrowRightEndOnRectangleIcon, ArrowRightEndOnRectangleIcon,
LockClosedIcon,
UserGroupIcon,
SignalIcon,
WifiIcon,
ChatBubbleLeftRightIcon, ChatBubbleLeftRightIcon,
DocumentTextIcon,
ShieldCheckIcon,
HomeIcon,
} from '@heroicons/vue/20/solid'; } from '@heroicons/vue/20/solid';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
import { scrollbarOptions } from '../composables/useScrollbar';
import { THEME_ICONS, THEME_LOGOS, THEME_NAMES, useTheme } from '../composables/useTheme'; import { THEME_ICONS, THEME_LOGOS, THEME_NAMES, useTheme } from '../composables/useTheme';
import { ws, auth, agents, takeover, lastViewerPath, useViewerStore } from '../store'; import { auth } from '../store';
import { useChatStore } from '../store/chat';
import FileTree from './FileTree.vue';
const takeoverToken = takeover.token;
const captureActive = takeover.capture.isActive;
const takeoverPanelOpen = ref(false);
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);
}
const systemOpen = ref(sessionStorage.getItem('sidebar_panel_system') === 'true');
const connPanelOpen = ref(false);
const anyPanelOpen = computed(() => takeoverPanelOpen.value || userMenuOpen.value || versionPanelOpen.value || connPanelOpen.value);
function closeAllPanels() {
takeoverPanelOpen.value = false;
userMenuOpen.value = false;
versionPanelOpen.value = false;
connPanelOpen.value = false;
}
function toggleConnPanel() {
const opening = !connPanelOpen.value;
closeAllPanels();
if (opening) connPanelOpen.value = true;
}
function toggleTakeoverPanel() {
if (!takeoverToken.value) { router.push('/dev'); return; }
const opening = !takeoverPanelOpen.value;
closeAllPanels();
if (opening) takeoverPanelOpen.value = true;
}
function toggleUserMenu() {
const opening = !userMenuOpen.value;
closeAllPanels();
if (opening) userMenuOpen.value = true;
}
function revokeAndClose() {
takeover.revoke();
closeAllPanels();
}
const homeAgent = computed(() => allAgents.value.find(a => a.id === defaultAgent.value));
const allFilteredSorted = computed(() =>
filteredAgents.value
.filter(a => a.id !== defaultAgent.value)
.sort((a, b) => a.name.localeCompare(b.name))
);
const chatStore = useChatStore();
// Viewer file tree in sidebar
const viewerStore = useViewerStore();
const viewerToken = computed(() => viewerStore.fstoken);
const viewerRoots = computed(() => viewerStore.roots);
const sharedOpen = ref(lastViewerPath.value.startsWith('shared'));
const workspaceOpen = ref(lastViewerPath.value.startsWith('workspace'));
function toggleFileSection(section: 'shared' | 'workspace') {
const root = section === 'shared' ? 'shared' : viewerWorkspaceRoot.value;
if (section === 'shared') {
sharedOpen.value = !sharedOpen.value;
if (sharedOpen.value) { workspaceOpen.value = false; openInViewer(root); }
} else {
workspaceOpen.value = !workspaceOpen.value;
if (workspaceOpen.value) { sharedOpen.value = false; openInViewer(root); }
}
}
const viewerWorkspaceRoot = computed(() => {
const ws = viewerRoots.value.find(r => r.startsWith('workspace'));
return ws || 'workspace-titan';
});
function openInViewer(path: string) {
lastViewerPath.value = path;
localStorage.setItem('viewer_last_path', path);
router.push({ name: 'viewer', query: { path } });
}
// Version display
import { useUI } from '../composables/ui';
const { version } = useUI(ws.status);
const versionShort = version.split('-')[0];
const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
const versionPanelOpen = ref(false);
const versionCopied = ref(false);
function toggleVersionPanel() {
const opening = !versionPanelOpen.value;
closeAllPanels();
if (opening) versionPanelOpen.value = true;
}
function copyVersionDetails() {
const details = `env: ${envLabel}\nfe: ${version}\nbe: ${chatStore.beVersion || 'unknown'}\nua: ${navigator.userAgent}`;
navigator.clipboard.writeText(details);
versionCopied.value = true;
setTimeout(() => { versionCopied.value = false; }, 2000);
}
function stateToIndicator(state: string | null) {
switch (state) {
case 'AGENT_RUNNING': return { icon: '⚙️', cls: 'ch-running' };
case 'FRESH': return { icon: '✨', cls: 'ch-fresh' };
case 'READY': return { icon: '●', cls: 'ch-ready' };
case 'HANDOVER_PENDING': return { icon: '📝', cls: 'ch-handover' };
case 'HANDOVER_DONE': return { icon: '✅', cls: 'ch-handover' };
case 'RESETTING': return { icon: '🔄', cls: 'ch-resetting' };
case 'NO_SESSION': return { icon: '○', cls: 'ch-nosession' };
default: return { icon: '·', cls: 'ch-none' };
}
}
// For selected agent: use live WS state from chat store
const selectedIndicator = computed(() => stateToIndicator(chatStore.channelState));
// For any agent: get from HTTP-polled channel states
function agentIndicator(agentId: string, mode: 'private' | 'public' = 'private') {
// For selected agent + mode: use live WS state only when SYNCED
if (agentId === selectedAgent.value && mode === selectedMode.value && chatStore.connectionState === 'SYNCED') {
return selectedIndicator.value;
}
const info = agents.getChannelState(agentId, mode);
return info ? stateToIndicator(info.state) : stateToIndicator(null);
}
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');
const emit = defineEmits<{ logout: [] }>(); const emit = defineEmits<{ logout: [] }>();
@ -447,39 +95,7 @@ const router = useRouter();
const { theme } = useTheme(); const { theme } = useTheme();
const navLogo = computed(() => THEME_LOGOS[theme.value]); const navLogo = computed(() => THEME_LOGOS[theme.value]);
const { isLoggedIn } = auth; const { isLoggedIn, currentUser } = auth;
const { currentUser, send } = ws;
const { filteredAgents, selectedAgent, selectedMode, defaultAgent, allAgents } = agents;
function defaultMode(agent: any): string {
if (agent.role === 'owner') return 'private';
if (agent.modes?.includes('public')) return 'public';
return 'private';
}
function extraMode(agent: any): string | null {
const def = defaultMode(agent);
const other = def === 'private' ? 'public' : 'private';
return agent.modes?.includes(other) ? other : null;
}
function activeMode(agent: any): string | null {
if (selectedAgent.value !== agent.id) return null;
return selectedMode.value;
}
const SEGMENTS = ['personal', 'common', 'private', 'public'] as const;
const visibleSegments = computed(() =>
SEGMENTS
.map(key => ({
key,
agents: filteredAgents.value
.filter(a => (a.segment ?? 'utility') === key && a.id !== defaultAgent.value)
.sort((a, b) => a.name.localeCompare(b.name)),
}))
.filter(s => s.agents.length > 0)
);
const isMobile = window.innerWidth <= 480; const isMobile = window.innerWidth <= 480;
const isLarge = window.innerWidth >= 1024; const isLarge = window.innerWidth >= 1024;
@ -504,39 +120,18 @@ function toggle() {
} }
function collapse() { function collapse() {
if (window.innerWidth >= 1024) return; // large screens: stay open if (window.innerWidth >= 1024) return;
isOpen.value = false; isOpen.value = false;
localStorage.setItem('sidebar_open', 'false'); localStorage.setItem('sidebar_open', 'false');
} }
function goAgentsOverview() { function goNyx() {
collapse(); collapse();
router.push({ name: 'agents', query: {} }); router.push({ name: 'nyx' });
} }
function toggleUserMenu() {
userMenuOpen.value = !userMenuOpen.value;
function handleModeClick(agentId: string, mode: string) {
collapse();
const sameAgent = selectedAgent.value === agentId && selectedMode.value === mode;
// Already on this agent+mode on agents view nothing to do
if (sameAgent && route.name === 'agents') return;
// Same agent but on a different view just navigate back, don't re-switch
if (sameAgent) {
router.push({ name: 'agents', query: { agent: agentId, mode } });
return;
}
// Different agent full switch
selectedAgent.value = agentId;
selectedMode.value = mode as 'private' | 'public';
sessionStorage.setItem('agent', agentId);
sessionStorage.setItem('agent_mode', mode);
if (ws.connected.value) {
ws.switchAgent(agentId, mode);
} else {
ws.connect(selectedAgent, auth.isLoggedIn, auth.loginError, selectedMode);
}
router.push({ name: 'agents', query: { agent: agentId, mode } });
} }
function handleLogout() { function handleLogout() {

View File

@ -0,0 +1,271 @@
<template>
<div v-if="isLoggedIn" class="app-toolbar">
<!-- Connection pill -->
<button class="toolbar-pill" :class="{ active: connActive }" @click="togglePanel('conn')" :title="connLabel || 'Connection'">
<WifiIcon class="w-4 h-4" />
<span v-if="connLabel" class="toolbar-pill-label">{{ connLabel }}</span>
</button>
<div class="toolbar-spacer" />
<!-- Theme pills (icon only) -->
<button
v-for="t in THEMES"
:key="t"
class="toolbar-pill toolbar-pill-icon"
:class="{ active: theme === t }"
@click="setTheme(t)"
:title="THEME_NAMES[t]"
>
<component :is="THEME_ICONS[t]" class="w-4 h-4" />
</button>
<!-- Takeover / Capture -->
<button
v-if="takeoverToken"
class="toolbar-pill"
:class="{ active: captureActive }"
@click="togglePanel('takeover')"
title="Takeover"
>
<SignalIcon class="w-4 h-4" />
</button>
<!-- Version pill -->
<button class="toolbar-pill toolbar-pill-dim" @click="copyVersionDetails" :title="versionFull">
<span class="toolbar-version-text">{{ versionShort }}</span>
</button>
<!-- Panels (dropdown from toolbar) -->
<div v-if="openPanel" class="toolbar-panel-backdrop" @click="openPanel = null" />
<!-- Connection panel -->
<div v-if="openPanel === 'conn'" class="toolbar-panel" style="right: auto; left: 0;">
<div class="toolbar-panel-header">Connection</div>
<div class="toolbar-panel-row"><span>HTTP</span><span>{{ chatStore.connectionState }}</span></div>
<div class="toolbar-panel-row"><span>Channel</span><span>{{ chatStore.channelState }}</span></div>
<div class="toolbar-panel-row"><span>Agent</span><span>{{ selectedAgent || 'none' }}</span></div>
<div class="toolbar-panel-row"><span>Mode</span><span>{{ selectedMode }}</span></div>
</div>
<!-- Takeover panel -->
<div v-if="openPanel === '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 } from 'vue';
import { WifiIcon, SignalIcon } from '@heroicons/vue/20/solid';
import { THEME_ICONS, THEME_NAMES, useTheme, type Theme } from '../composables/useTheme';
import { ws, auth, agents, takeover } from '../store';
import { useChatStore } from '../store/chat';
import { useUI } from '../composables/ui';
const THEMES: Theme[] = ['loop42', 'titan', 'eras'];
const { isLoggedIn } = auth;
const { selectedAgent, selectedMode } = agents;
const { theme, setTheme } = useTheme();
const chatStore = useChatStore();
// 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
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();
openPanel.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 || '...'}`);
const versionCopied = ref(false);
function copyVersionDetails() {
const details = `env: ${envLabel}\nfe: ${version}\nbe: ${chatStore.beVersion || 'unknown'}\nua: ${navigator.userAgent}`;
navigator.clipboard.writeText(details);
versionCopied.value = true;
setTimeout(() => { versionCopied.value = false; }, 2000);
}
// Panel toggle
const openPanel = ref<'conn' | 'takeover' | null>(null);
function togglePanel(panel: 'conn' | 'takeover') {
openPanel.value = openPanel.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; }
.toolbar-pill {
display: flex;
align-items: center;
gap: 6px;
background: var(--panel-bg);
border-radius: var(--radius-panel, 12px);
box-shadow: var(--panel-shadow);
padding: 0 12px;
height: 34px;
font-size: 0.85rem;
color: var(--text-dim);
border: none;
cursor: pointer;
transition: color 0.12s, background 0.12s;
white-space: nowrap;
}
.toolbar-pill:hover { color: var(--text); }
.toolbar-pill.active { color: var(--accent); }
.toolbar-pill-icon {
width: 34px;
padding: 0;
justify-content: center;
}
.toolbar-pill-dim {
opacity: 0.5;
}
.toolbar-pill-dim:hover { opacity: 0.8; }
.toolbar-pill-label {
overflow: hidden;
text-overflow: ellipsis;
}
.toolbar-version-text {
font-size: 0.65rem;
}
/* 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); }
/* Mobile: hide labels, shrink pills */
@media (max-width: 480px) {
.toolbar-pill-label { display: none; }
.toolbar-panel { width: min(220px, calc(100vw - 16px)); }
}
</style>

View File

@ -1,80 +1,222 @@
import { ref, type Ref } from 'vue'; /**
* auth.ts Zitadel OIDC PKCE authentication
*
* Flow:
* 1. User clicks "Sign in" redirect to Zitadel authorize endpoint
* 2. Zitadel authenticates redirects back with ?code=...
* 3. We exchange code for tokens (access_token + id_token)
* 4. access_token stored in localStorage, used for all /api/* requests
* 5. On load: check if token exists and is not expired
*/
import { ref, computed, type Ref } from 'vue';
import router from '../router'; import router from '../router';
const SESSION_TOKEN_KEY = 'nyx_session';
import { getApiBase } from '../utils/apiBase'; import { getApiBase } from '../utils/apiBase';
// Dev service token — auto-login for development const TOKEN_KEY = 'nyx_session';
const DEV_TOKEN = '7Oorb9S3OpwFyWgm4zi_Tq7GeamefbjjTgooPVPWAwPDOf6B4TvgvQlLbhmT4DjsqBS_D1g'; const VERIFIER_KEY = 'pkce_verifier';
const RETURN_KEY = 'auth_return_path';
// Zitadel config — fetched from backend or hardcoded fallback
let _issuer = 'https://auth.loop42.de';
let _clientId = '365996029172056091';
const _redirectUri = `${window.location.origin}/login`;
const _scopes = 'openid profile email';
async function _fetchAuthConfig(): Promise<void> {
try {
const base = getApiBase();
const res = await fetch(`${base}/auth/config`);
if (res.ok) {
const cfg = await res.json();
if (cfg.issuer) _issuer = cfg.issuer;
if (cfg.clientId) _clientId = cfg.clientId;
}
} catch { /* use defaults */ }
}
// PKCE helpers
function _generateVerifier(): string {
const arr = new Uint8Array(32);
crypto.getRandomValues(arr);
return btoa(String.fromCharCode(...arr))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function _generateChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function _isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 < Date.now();
} catch {
return true;
}
}
function _getStoredToken(): string | null {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return null;
if (_isTokenExpired(token)) {
localStorage.removeItem(TOKEN_KEY);
return null;
}
return token;
}
function _getUserNameFromToken(): string {
// Prefer cached name from id_token (access_token may lack profile claims)
const cached = localStorage.getItem('nyx_user');
if (cached) return cached;
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return '';
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.name || payload.preferred_username || payload.email || '';
} catch {
return '';
}
}
export function useAuth(connectFn: () => void) { export function useAuth(connectFn: () => void) {
// Auto-set dev token if no token exists const isLoggedIn: Ref<boolean> = ref(!!_getStoredToken());
if (!localStorage.getItem(SESSION_TOKEN_KEY) && !localStorage.getItem('titan_token')) {
localStorage.setItem(SESSION_TOKEN_KEY, DEV_TOKEN);
}
const isLoggedIn: Ref<boolean> = ref(!!localStorage.getItem(SESSION_TOKEN_KEY));
const loginToken: Ref<string> = ref('');
const loginError: Ref<string> = ref(''); const loginError: Ref<string> = ref('');
const loggingIn: Ref<boolean> = ref(false); const loggingIn: Ref<boolean> = ref(false);
// Legacy compat — not used in PKCE flow but some UI may reference it
const loginToken: Ref<string> = ref('');
const currentUser = computed(() => isLoggedIn.value ? _getUserNameFromToken() : '');
/**
* Start PKCE login redirect to Zitadel.
* Stores current path so we can return after login.
*/
async function doLogin(): Promise<void> { async function doLogin(): Promise<void> {
const token = loginToken.value.trim();
if (!token) return;
loggingIn.value = true; loggingIn.value = true;
loginError.value = ''; loginError.value = '';
try { await _fetchAuthConfig();
const nonceRes = await fetch(`${getApiBase()}/api/auth/nonce`);
if (!nonceRes.ok) { loginError.value = 'Auth unavailable'; loggingIn.value = false; return; } const verifier = _generateVerifier();
const { nonce } = await nonceRes.json(); const challenge = await _generateChallenge(verifier);
const res = await fetch(`${getApiBase()}/api/auth`, { sessionStorage.setItem(VERIFIER_KEY, verifier);
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // Remember where to go after login
body: JSON.stringify({ token, nonce }), const returnPath = window.location.pathname;
}); if (returnPath !== '/login') {
if (!res.ok) { sessionStorage.setItem(RETURN_KEY, returnPath);
const err = await res.json().catch(() => ({ error: 'Login failed' }));
loginError.value = err.error || 'Invalid token';
loggingIn.value = false;
return;
} }
const { sessionToken } = await res.json();
localStorage.removeItem('titan_token'); const params = new URLSearchParams({
localStorage.removeItem('nyx_token'); response_type: 'code',
localStorage.setItem(SESSION_TOKEN_KEY, sessionToken); client_id: _clientId,
sessionStorage.removeItem('agent'); redirect_uri: _redirectUri,
scope: _scopes,
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location.href = `${_issuer}/oauth/v2/authorize?${params}`;
}
/**
* Handle callback from Zitadel exchange code for token.
* Called from LoginView when ?code= is in the URL.
*/
async function handleCallback(code: string): Promise<boolean> {
loggingIn.value = true;
loginError.value = '';
const verifier = sessionStorage.getItem(VERIFIER_KEY);
if (!verifier) {
loginError.value = 'Missing PKCE verifier — please try again.';
loggingIn.value = false;
return false;
}
sessionStorage.removeItem(VERIFIER_KEY);
try {
const res = await fetch(`${_issuer}/oauth/v2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: _clientId,
code,
redirect_uri: _redirectUri,
code_verifier: verifier,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
loginError.value = err.error_description || err.error || 'Token exchange failed';
loggingIn.value = false;
return false;
}
const data = await res.json();
const token = data.access_token;
if (!token) {
loginError.value = 'No access token received';
loggingIn.value = false;
return false;
}
// Extract user name from id_token (access_token may not have profile claims)
if (data.id_token) {
try {
const idPayload = JSON.parse(atob(data.id_token.split('.')[1]));
const name = idPayload.name || idPayload.preferred_username || idPayload.email || '';
if (name) localStorage.setItem('nyx_user', name);
} catch { /* ignore */ }
}
// Store token and mark logged in
localStorage.setItem(TOKEN_KEY, token);
isLoggedIn.value = true;
// Connect transport
connectFn();
// Navigate to return path or /nyx
const returnPath = sessionStorage.getItem(RETURN_KEY) || '/nyx';
sessionStorage.removeItem(RETURN_KEY);
router.push(returnPath);
loggingIn.value = false;
return true;
} catch (e) {
loginError.value = 'Network error during token exchange';
loggingIn.value = false;
return false;
}
}
/**
* Auto-connect if we have a valid token on page load.
*/
function tryAutoConnect(): void {
if (_getStoredToken()) {
isLoggedIn.value = true; isLoggedIn.value = true;
connectFn(); connectFn();
router.push('/chat');
setTimeout(() => { loggingIn.value = false; }, 500);
} catch {
loginError.value = 'Network error';
loggingIn.value = false;
} }
} }
async function doLogout(disconnectFn?: () => void): Promise<void> { async function doLogout(disconnectFn?: () => void): Promise<void> {
const sessionToken = localStorage.getItem(SESSION_TOKEN_KEY);
if (sessionToken) {
// Fire-and-forget revoke
fetch(`${getApiBase()}/api/auth/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionToken }),
}).catch(() => {});
}
if (disconnectFn) disconnectFn(); if (disconnectFn) disconnectFn();
localStorage.removeItem(SESSION_TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem('titan_token'); localStorage.removeItem('nyx_user');
localStorage.removeItem('nyx_token'); sessionStorage.removeItem('nyx_ws_session');
sessionStorage.removeItem('agent'); sessionStorage.removeItem('agent');
sessionStorage.removeItem('viewer_auth'); // clear cached fstoken
isLoggedIn.value = false; isLoggedIn.value = false;
loginToken.value = '';
loggingIn.value = false; loggingIn.value = false;
router.push('/'); router.push('/');
} }
return { isLoggedIn, loginToken, loginError, loggingIn, doLogin, doLogout }; return { isLoggedIn, loginToken, loginError, loggingIn, currentUser, doLogin, doLogout, handleCallback, tryAutoConnect };
} }

View File

@ -56,7 +56,7 @@ export function useBreakout() {
pendingRequest.value = null; pendingRequest.value = null;
if (!confirmed) { resolve({ error: 'rejected by user' }); return; } if (!confirmed) { resolve({ error: 'rejected by user' }); return; }
const token = deriveToken(parentToken, name); const token = deriveToken(parentToken, name);
const url = `${window.location.origin}${window.location.pathname}?breakout_token=${token}/agents`; const url = `${window.location.origin}${window.location.pathname}?breakout_token=${token}/nyx`;
const popup = window.open(url, `hermes_breakout_${name}`, `width=${pw},height=${ph},resizable=yes,scrollbars=yes`); const popup = window.open(url, `hermes_breakout_${name}`, `width=${pw},height=${ph},resizable=yes,scrollbars=yes`);
if (!popup) { resolve({ error: 'popup blocked' }); return; } if (!popup) { resolve({ error: 'popup blocked' }); return; }
windows.set(name, popup); windows.set(name, popup);
@ -70,7 +70,7 @@ export function useBreakout() {
function openDirect(name: string, presetStr: string, parentToken: string) { function openDirect(name: string, presetStr: string, parentToken: string) {
const [w, h] = presetStr.split('x').map(Number); const [w, h] = presetStr.split('x').map(Number);
const token = deriveToken(parentToken, name); const token = deriveToken(parentToken, name);
const url = `${window.location.origin}${window.location.pathname}?breakout_token=${token}/agents`; const url = `${window.location.origin}${window.location.pathname}?breakout_token=${token}/nyx`;
const popup = window.open(url, `hermes_breakout_${name}`, `width=${w},height=${h},resizable=yes,scrollbars=yes`); const popup = window.open(url, `hermes_breakout_${name}`, `width=${w},height=${h},resizable=yes,scrollbars=yes`);
if (!popup) { alert('Popup blocked -- allow popups for this site'); return; } if (!popup) { alert('Popup blocked -- allow popups for this site'); return; }
windows.set(name, popup); windows.set(name, popup);

View File

@ -1,8 +1,12 @@
/** /**
* ws.ts WebSocket transport layer * ws.ts Streamable HTTP transport layer
* *
* Pure connection management: connect, disconnect, reconnect, send, onMessage. * Replaced WebSocket with HTTP POST + SSE streaming.
* Delegates takeover commands to useTakeover. * Each message = one POST /api/chat, response is SSE stream (delta, hud, done, etc.).
* Session init = GET /api/session.
*
* Keeps the same API surface (connect, send, onMessage, connected, etc.)
* so useAgentSocket.ts and all consumers work unchanged.
* *
* All mutable state is module-level so it survives Vite HMR. * All mutable state is module-level so it survives Vite HMR.
* useWebSocket() returns a stable API over that shared state. * useWebSocket() returns a stable API over that shared state.
@ -22,22 +26,18 @@ interface MessagePayload {
// ── Module-level state — stored on window.__hermes to survive Vite HMR ── // ── Module-level state — stored on window.__hermes to survive Vite HMR ──
const H = useHermes(); const H = useHermes();
// WebSocket + ping timer
let _ws: WebSocket | null = H.ws ?? null;
let _pingInterval: ReturnType<typeof setInterval> | null = H.wsPing ?? null;
// Callbacks + buffer // Callbacks + buffer
const _onMessageCallbacks: ((data: any) => void)[] = H.wsCbs ?? []; const _onMessageCallbacks: ((data: any) => void)[] = H.wsCbs ?? [];
const _messageBuffer: any[] = H.wsBuf ?? []; const _messageBuffer: any[] = H.wsBuf ?? [];
// Reconnect
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let _reconnectDelay = 1000;
let _pendingAuth = false; // WS open but agent was empty — send auth when agent is set
// Refs (re-bound on connect) // Refs (re-bound on connect)
let _selectedAgentRef: Ref<string> | null = null;
let _selectedModeRef: Ref<string> | null = null;
let _isLoggedInRef: Ref<boolean> | null = null; let _isLoggedInRef: Ref<boolean> | null = null;
let _loginErrorRef: Ref<string> | null = null; let _loginErrorRef: Ref<string> | null = null;
let _takeover: ReturnType<typeof useTakeover> | null = null; let _takeover: ReturnType<typeof useTakeover> | null = null;
// Abort controller for in-flight chat request
let _chatAbort: AbortController | null = null;
// Health stream abort + reconnect
let _healthAbort: AbortController | null = null;
let _healthReconnectTimer: ReturnType<typeof setTimeout> | null = null;
const connected = H.wsConnected ?? ref(false); const connected = H.wsConnected ?? ref(false);
const status = H.wsStatus ?? ref('Disconnected'); const status = H.wsStatus ?? ref('Disconnected');
@ -54,8 +54,25 @@ H.wsInit = isInitialLoad;
H.wsCbs = _onMessageCallbacks; H.wsCbs = _onMessageCallbacks;
H.wsBuf = _messageBuffer; H.wsBuf = _messageBuffer;
function send(payload: MessagePayload): void { function getToken(): string {
if (_ws && _ws.readyState === WebSocket.OPEN) _ws.send(JSON.stringify(payload)); return localStorage.getItem('nyx_session') || '';
}
function getApiBase(): string {
const wsUrl = import.meta.env.VITE_WS_URL as string | undefined;
if (!wsUrl) return '';
try {
const url = new URL(wsUrl);
const proto = url.protocol === 'wss:' ? 'https:' : 'http:';
return `${proto}//${url.host}`;
} catch {
return '';
}
}
function _dispatch(data: any) {
_messageBuffer.push(data);
_onMessageCallbacks.forEach(fn => fn(data));
} }
function getTakeover() { function getTakeover() {
@ -63,132 +80,278 @@ function getTakeover() {
return _takeover; return _takeover;
} }
// Dev service token — skip login UI for now /**
const DEV_TOKEN = '7Oorb9S3OpwFyWgm4zi_Tq7GeamefbjjTgooPVPWAwPDOf6B4TvgvQlLbhmT4DjsqBS_D1g'; * Health stream: SSE connection for instant disconnect detection.
* Server sends heartbeat every 15s. If stream breaks disconnected.
*/
async function _startHealthStream(): Promise<void> {
if (_healthAbort) _healthAbort.abort();
_healthAbort = new AbortController();
const base = getApiBase();
const token = getToken();
function getWsUrl(): string { try {
let base: string; const res = await fetch(`${base}/api/health-stream`, {
if (import.meta.env.VITE_WS_URL) { headers: { 'Authorization': `Bearer ${token}` },
base = import.meta.env.VITE_WS_URL as string; signal: _healthAbort.signal,
} else { });
const params = new URLSearchParams(window.location.search); if (!res.ok) return;
const wsHost = params.get('ws') || window.location.hostname;
const wsPort = params.get('port') || window.location.port; const reader = res.body!.getReader();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const decoder = new TextDecoder();
base = `${protocol}//${wsHost}:${wsPort}/ws`; const wasDisconnected = !connected.value;
} connected.value = true;
// Append token + session as query params (assay auth pattern) status.value = 'Connected';
const token = localStorage.getItem('nyx_session') || localStorage.getItem('titan_token') || DEV_TOKEN; if (wasDisconnected) {
const session = sessionStorage.getItem('nyx_ws_session') || ''; _dispatch({ type: 'connection_state', state: 'SYNCED' });
const qs = new URLSearchParams();
if (token) qs.set('token', token);
if (session) qs.set('session', session);
return qs.toString() ? `${base}?${qs}` : base;
} }
function scheduleReconnect() { while (true) {
if (_reconnectTimer) return; const { done, value } = await reader.read();
if (!_isLoggedInRef?.value) return; if (done) break;
status.value = `Reconnecting in ${Math.round(_reconnectDelay / 1000)}s…`; const text = decoder.decode(value, { stream: true });
_reconnectTimer = setTimeout(() => { if (text.includes('heartbeat')) {
_reconnectTimer = null; connected.value = true;
if (_isLoggedInRef?.value) connect(_selectedAgentRef!, _isLoggedInRef!, _loginErrorRef!); status.value = 'Connected';
}, _reconnectDelay); }
_reconnectDelay = Math.min(_reconnectDelay * 2, 16000); }
} catch (e: any) {
if (e.name === 'AbortError') return;
} }
function connect( // Stream ended or errored — server gone
connected.value = false;
status.value = 'Disconnected';
_dispatch({ type: 'connection_state', state: 'CONNECTING' });
// Auto-reconnect after 3s
if (_healthReconnectTimer) clearTimeout(_healthReconnectTimer);
_healthReconnectTimer = setTimeout(() => {
_healthReconnectTimer = null;
if (_isLoggedInRef?.value !== false) _startHealthStream();
}, 3000);
}
/**
* Connect: fetch or create session via GET /api/session, then mark connected.
*/
async function connect(
selectedAgent: Ref<string>, selectedAgent: Ref<string>,
isLoggedInRef: Ref<boolean>, isLoggedInRef: Ref<boolean>,
loginErrorRef: Ref<string>, loginErrorRef: Ref<string>,
selectedMode?: Ref<string> _selectedMode?: Ref<string>
): void { ): Promise<void> {
if (_ws && _ws.readyState <= WebSocket.OPEN) return;
_selectedAgentRef = selectedAgent;
_selectedModeRef = selectedMode ?? null;
_isLoggedInRef = isLoggedInRef; _isLoggedInRef = isLoggedInRef;
_loginErrorRef = loginErrorRef; _loginErrorRef = loginErrorRef;
_reconnectDelay = 1000;
// Poll setup happens in onopen if agent is empty
console.log('WS CONNECT attempt, ws state:', _ws?.readyState, 'url:', getWsUrl());
const wsUrl = getWsUrl();
if (isInitialLoad.value) { if (isInitialLoad.value) {
status.value = 'Connecting...'; status.value = 'Connecting...';
isInitialLoad.value = false; isInitialLoad.value = false;
} }
_ws = new WebSocket(wsUrl); const base = getApiBase();
H.ws = _ws; const token = getToken();
const existingSession = sessionStorage.getItem('nyx_ws_session') || '';
const qs = new URLSearchParams();
if (existingSession) qs.set('session', existingSession);
_ws.onopen = () => {
_reconnectDelay = 1000;
// Auth is via query params — no auth message needed
// Just start ping keepalive
if (_pingInterval) clearInterval(_pingInterval);
_pingInterval = setInterval(() => {
if (_ws?.readyState === WebSocket.OPEN) _ws.send(JSON.stringify({ type: 'ping' }));
}, 30000);
H.wsPing = _pingInterval;
};
_ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const res = await fetch(`${base}/api/session?${qs}`, {
if (data.type === 'dev_cmd' && data.cmdId && data.cmd) { headers: { 'Authorization': `Bearer ${token}` },
getTakeover().dispatch(data.cmdId, data.cmd, data.args || {}, send); });
return; if (res.status === 401) {
}
// Session info from assay — store for reconnect
if (data.type === 'session_info') {
sessionId.value = data.session_id;
sessionStorage.setItem('nyx_ws_session', data.session_id);
}
// Ready signal from assay — mark connected
if (data.type === 'ready') {
connected.value = true;
status.value = 'Connected';
}
if (data.type === 'error' && data.code === 'SESSION_TERMINATED') {
console.warn('Message bounced: Session terminated.');
}
_messageBuffer.push(data);
_onMessageCallbacks.forEach(fn => fn(data));
} catch (e) {
console.error('Parse error:', e);
}
};
_ws.onclose = (e) => {
if (_pingInterval) { clearInterval(_pingInterval); _pingInterval = null; }
connected.value = false;
_messageBuffer.length = 0;
if (e.code === 4001) {
isLoggedInRef.value = false; isLoggedInRef.value = false;
loginErrorRef.value = 'Session expired. Please log in again.'; loginErrorRef.value = 'Session expired. Please log in again.';
localStorage.removeItem('nyx_session'); localStorage.removeItem('nyx_session');
localStorage.removeItem('titan_token'); localStorage.removeItem('titan_token');
sessionStorage.removeItem('agent');
status.value = 'Logged out'; status.value = 'Logged out';
// Redirect to login — avoids broken UI with stale state
if (window.location.pathname !== '/login') { if (window.location.pathname !== '/login') {
window.location.pathname = '/login'; window.location.pathname = '/login';
} }
} else { return;
scheduleReconnect();
} }
if (!res.ok) {
status.value = `Error ${res.status}`;
return;
}
const data = await res.json();
sessionId.value = data.session_id;
sessionStorage.setItem('nyx_ws_session', data.session_id);
// Dispatch session_info + ready (same as old WS handshake)
_dispatch({ type: 'session_info', session_id: data.session_id });
_dispatch({
type: 'ready',
session_id: data.session_id,
graph: data.graph,
history_len: data.history_len,
});
connected.value = true;
status.value = 'Connected';
// Start health stream for instant disconnect detection
_startHealthStream();
} catch (e) {
console.error('[http] connect failed:', e);
status.value = 'Connection failed';
}
}
/**
* Send a message via POST /api/chat. Response is SSE stream.
* Events are dispatched through the same onMessage callbacks.
*/
function send(payload: MessagePayload): void {
if (!connected.value) return;
const base = getApiBase();
const token = getToken();
// Build request body
const body: Record<string, any> = {
session_id: sessionId.value,
}; };
_ws.onerror = () => {}; if (payload.type === 'message') {
body.content = payload.content || '';
if (payload.dashboard) body.dashboard = payload.dashboard;
} else if (payload.type === 'action') {
body.action = payload.action;
if (payload.data) body.action_data = payload.data;
} else if (payload.type === 'new') {
// New session — use dedicated endpoint
_createNewSession();
return;
} else if (payload.type === 'stop') {
_stopPipeline();
return;
} else if (payload.type === 'ping') {
return; // No-op for HTTP
} else if (payload.type === 'switch_agent') {
// Agent switching not yet implemented in HTTP — ignore for now
return;
} else {
// Unknown type — skip
return;
}
// Abort previous in-flight request if any
if (_chatAbort) _chatAbort.abort();
_chatAbort = new AbortController();
_streamChat(base, token, body, _chatAbort.signal);
}
async function _streamChat(base: string, token: string, body: Record<string, any>, signal: AbortSignal): Promise<void> {
try {
const res = await fetch(`${base}/api/chat`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal,
});
if (res.status === 401) {
_isLoggedInRef!.value = false;
_loginErrorRef!.value = 'Session expired. Please log in again.';
status.value = 'Logged out';
return;
}
if (!res.ok) {
const detail = await res.text().catch(() => `${res.status}`);
console.error('[chat] HTTP error:', res.status, detail);
_dispatch({ type: 'error', detail });
return;
}
// Read SSE stream
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events from buffer
const lines = buffer.split('\n');
buffer = lines.pop()!; // Keep incomplete last line
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
_dispatch(data);
} catch (e) {
console.error('[chat] SSE parse error:', e, line);
}
}
// Skip event: lines — we get the type from the data payload
}
}
// Process any remaining buffer
if (buffer.startsWith('data: ')) {
try {
const data = JSON.parse(buffer.slice(6));
_dispatch(data);
} catch { /* ignore partial */ }
}
} catch (e: any) {
if (e.name === 'AbortError') return; // Intentional abort
console.error('[chat] stream error:', e);
} finally {
_chatAbort = null;
}
}
async function _createNewSession(): Promise<void> {
const base = getApiBase();
const token = getToken();
try {
const res = await fetch(`${base}/api/sessions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!res.ok) return;
const data = await res.json();
sessionId.value = data.session_id;
sessionStorage.setItem('nyx_ws_session', data.session_id);
_dispatch({ type: 'session_info', session_id: data.session_id });
_dispatch({ type: 'cleared' });
} catch (e) {
console.error('[http] new session failed:', e);
}
}
async function _stopPipeline(): Promise<void> {
const base = getApiBase();
const token = getToken();
try {
await fetch(`${base}/api/stop`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
});
if (_chatAbort) _chatAbort.abort();
_dispatch({ type: 'stopped' });
} catch (e) {
console.error('[http] stop failed:', e);
}
} }
function disconnect(): void { function disconnect(): void {
if (_pingInterval) { clearInterval(_pingInterval); _pingInterval = null; H.wsPing = null; } if (_chatAbort) { _chatAbort.abort(); _chatAbort = null; }
if (_ws) { if (_healthAbort) { _healthAbort.abort(); _healthAbort = null; }
_ws.onclose = null; if (_healthReconnectTimer) { clearTimeout(_healthReconnectTimer); _healthReconnectTimer = null; }
_ws.close();
_ws = null;
H.ws = null;
}
connected.value = false; connected.value = false;
status.value = 'Disconnected'; status.value = 'Disconnected';
currentUser.value = ''; currentUser.value = '';
@ -196,23 +359,9 @@ function disconnect(): void {
isInitialLoad.value = true; isInitialLoad.value = true;
} }
function sendDeferredAuth(): void {
// Auth is via query params now — no-op for backward compat
}
// Poll for agent becoming available after deferred auth (no Vue watch to avoid circular imports)
let _deferredAuthPoll: ReturnType<typeof setInterval> | null = null;
function setupDeferredAuthPoll() {
if (_deferredAuthPoll) return;
_deferredAuthPoll = setInterval(() => {
if (!_pendingAuth) { clearInterval(_deferredAuthPoll!); _deferredAuthPoll = null; return; }
sendDeferredAuth();
}, 100);
}
function switchAgent(agentId: string, mode?: string): void { function switchAgent(agentId: string, mode?: string): void {
_messageBuffer.length = 0; _messageBuffer.length = 0;
send({ type: 'switch_agent', agent: agentId, mode: mode ?? _selectedModeRef?.value ?? 'private' }); send({ type: 'switch_agent', agent: agentId, mode: mode ?? 'private' });
} }
function clearBuffer(): void { function clearBuffer(): void {
@ -231,6 +380,9 @@ function replayBuffer(fn: (data: any) => void): void {
_messageBuffer.forEach(data => fn(data)); _messageBuffer.forEach(data => fn(data));
} }
// Backward compat — not needed for HTTP but some code may reference it
function sendDeferredAuth(): void {}
export function useWebSocket() { export function useWebSocket() {
return { return {
connected, status, currentUser, sessionId, connected, status, currentUser, sessionId,

View File

@ -28,7 +28,6 @@ import '../css/markdown.css';
import '../css/views/agents.css'; import '../css/views/agents.css';
import '../css/views/home.css'; import '../css/views/home.css';
import '../css/views/login.css'; import '../css/views/login.css';
import '../css/views/dev.css';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';

View File

@ -9,14 +9,25 @@ const router = createRouter({
{ path: '/impressum', name: 'impressum', component: () => import('./views/ImpressumView.vue'), meta: { suffix: 'Impressum' } }, { path: '/impressum', name: 'impressum', component: () => import('./views/ImpressumView.vue'), meta: { suffix: 'Impressum' } },
{ path: '/datenschutz', name: 'datenschutz', component: () => import('./views/DatenschutzView.vue'), meta: { suffix: 'Datenschutz' } }, { path: '/datenschutz', name: 'datenschutz', component: () => import('./views/DatenschutzView.vue'), meta: { suffix: 'Datenschutz' } },
{ path: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } }, { path: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } },
{ path: '/agents', name: 'agents', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'Home', requiresSocket: true } }, { path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } },
{ path: '/chat', redirect: '/agents' }, { path: '/agents', redirect: '/nyx' },
{ path: '/dev', name: 'dev', component: () => import('./views/DevView.vue'), meta: { suffix: 'Dev', requiresSocket: true } }, { path: '/chat', redirect: '/nyx' },
{ path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresSocket: true } }, { path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresAuth: true } },
{ path: '/:pathMatch(.*)*', redirect: '/' }, { path: '/:pathMatch(.*)*', redirect: '/' },
], ],
}); });
// Auth guard — redirect to /login for protected routes
router.beforeEach((to) => {
if (to.meta?.requiresAuth) {
const token = localStorage.getItem('nyx_session');
if (!token) {
sessionStorage.setItem('auth_return_path', to.fullPath);
return { name: 'login' };
}
}
});
router.afterEach((to) => { router.afterEach((to) => {
const { theme } = useTheme(); const { theme } = useTheme();
const brand = THEME_NAMES[theme.value] || 'loop42'; const brand = THEME_NAMES[theme.value] || 'loop42';

View File

@ -28,12 +28,14 @@ export const takeover = ws.getTakeover();
export const agents = useAgents(ws.connected); export const agents = useAgents(ws.connected);
agents.setCurrentUser(ws.currentUser); agents.setCurrentUser(ws.currentUser);
// Auth — on login, connect WS (sends auth — BE returns agent list even without agent selected) // Auth — on login/auto-connect, start HTTP transport
export const auth = useAuth(() => { export const auth = useAuth(() => {
ws.connect(agents.selectedAgent, auth.isLoggedIn, auth.loginError, agents.selectedMode); ws.connect(agents.selectedAgent, auth.isLoggedIn, auth.loginError, agents.selectedMode);
// Pre-warm viewer token in background so agent→viewer is instant // Pre-warm viewer token in background so agent→viewer is instant
useViewerStore().acquire(); useViewerStore().acquire();
}); });
// Auto-connect if valid token exists from previous session (deferred so Pinia is ready)
setTimeout(() => auth.tryAutoConnect(), 0);
// Re-export viewer store accessor for use in views // Re-export viewer store accessor for use in views
export { useViewerStore }; export { useViewerStore };

View File

@ -239,15 +239,8 @@ import { getApiBase } from '../utils/apiBase';
const agentsRoute = useRoute(); const agentsRoute = useRoute();
const chatStore = useChatStore(); const chatStore = useChatStore();
// Show picker when route has no ?agent= param (overview mode) // No picker always show chat.
const showPicker = computed(() => !agentsRoute.query.agent); const showPicker = computed(() => false);
// When navigating to /agents without params, clear selection so picker shows
watch(() => agentsRoute.query.agent, (val) => {
if (!val && agentsRoute.name === 'agents') {
// Don't clear selectedAgent ref just show picker via showPicker computed
}
}, { immediate: true });
// Previous sessions (server-fetched, supports load-more) // Previous sessions (server-fetched, supports load-more)
interface PrevSession { messages: any[]; timestamp: string | null; timeLabel: string } interface PrevSession { messages: any[]; timestamp: string | null; timeLabel: string }
const prevSessions = ref<PrevSession[]>([]); const prevSessions = ref<PrevSession[]>([]);
@ -316,7 +309,7 @@ function pickAgent(agentId: string, mode: string) {
sessionStorage.setItem('agent', agentId); sessionStorage.setItem('agent', agentId);
sessionStorage.setItem('agent_mode', mode); sessionStorage.setItem('agent_mode', mode);
// Update URL with query params so showPicker switches to chat // Update URL with query params so showPicker switches to chat
router.push({ name: 'agents', query: { agent: agentId, mode } }); router.push({ name: 'nyx', query: { agent: agentId, mode } });
if (ws.connected.value) { if (ws.connected.value) {
ws.switchAgent(agentId, mode); ws.switchAgent(agentId, mode);
} else { } else {
@ -326,6 +319,18 @@ function pickAgent(agentId: string, mode: string) {
const agentLogo = computed(() => getAgentLogo(selectedAgent.value)); const agentLogo = computed(() => getAgentLogo(selectedAgent.value));
// Auto-select default agent when navigating to /nyx without ?agent= param
// Watch both query.agent and defaultAgent defaultAgent arrives from server after connect
watch(
[() => agentsRoute.query.agent, defaultAgent],
([queryAgent, defAgent]) => {
if (!queryAgent && agentsRoute.name === 'nyx' && defAgent && !selectedAgent.value) {
pickAgent(defAgent, 'private');
}
},
{ immediate: true },
);
const { sending, input, messagesEl, scrollToBottom, scrollIfAtBottom, send: _send, onInputChange, navigateHistory, restoreLastSent } = useMessages(wsSend); const { sending, input, messagesEl, scrollToBottom, scrollIfAtBottom, send: _send, onInputChange, navigateHistory, restoreLastSent } = useMessages(wsSend);
const controlsEl = ref<HTMLElement | null>(null); const controlsEl = ref<HTMLElement | null>(null);
@ -374,7 +379,7 @@ if (typeof window !== 'undefined') {
(window as any).__toolCallMap = toolCallMapSnapshot; (window as any).__toolCallMap = toolCallMapSnapshot;
} }
const viewActive = computed(() => agentsRoute.name === 'agents'); const viewActive = computed(() => agentsRoute.name === 'nyx');
// --- Scroll logic --- // --- Scroll logic ---
// On user send: scroll so user message sits at top of viewport. // On user send: scroll so user message sits at top of viewport.

View File

@ -5,7 +5,6 @@
<p>Eigene Plattform. Eigene Infrastruktur. Eigene Regeln.</p> <p>Eigene Plattform. Eigene Infrastruktur. Eigene Regeln.</p>
</div> </div>
<hr class="divider">
<section id="plattform"> <section id="plattform">
<div class="section-label">Plattform</div> <div class="section-label">Plattform</div>
@ -26,7 +25,6 @@
</div> </div>
</section> </section>
<hr class="divider">
<section id="produkte"> <section id="produkte">
<div class="section-label">Produkte</div> <div class="section-label">Produkte</div>
@ -43,37 +41,22 @@
</div> </div>
</section> </section>
<hr class="divider">
<div class="cta"> <div class="cta">
<h2>nyx</h2> <h2>nyx</h2>
<p>Produkte kennenlernen, ausprobieren, Zugang einrichten direkt hier, direkt mit nyx.</p> <p>Produkte kennenlernen, ausprobieren, Zugang einrichten direkt hier, direkt mit nyx.</p>
<router-link to="/agents" class="btn">nyx öffnen</router-link> <router-link to="/nyx" class="btn">nyx öffnen</router-link>
</div> </div>
<footer> <footer>
<div class="footer-links"> <span>© 2026 loop42 GmbH</span>
<router-link to="/impressum">Impressum</router-link>
<router-link to="/datenschutz">Datenschutz</router-link>
</div>
<span>© 2026 loop42 UG (haftungsbeschränkt)</span>
</footer> </footer>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.company-page { .company-page {
--cp-bg: #0a0a0a; color: var(--text);
--cp-bg2: #111;
--cp-border: #1e1e1e;
--cp-text: #e8e8e8;
--cp-muted: rgba(232,232,232,0.4);
--cp-accent: #7c6af7;
--cp-accent-dim: rgba(124,106,247,0.12);
color: var(--cp-text);
overflow-y: auto;
height: 100%;
} }
.hero { .hero {
@ -96,19 +79,19 @@
.hero h1 em { .hero h1 em {
font-style: normal; font-style: normal;
color: var(--cp-accent); color: var(--accent);
} }
.hero p { .hero p {
font-size: 1.1rem; font-size: 1.1rem;
color: var(--cp-muted); color: var(--text-dim);
max-width: 480px; max-width: 480px;
margin: 0 auto 2.5rem; margin: 0 auto 2.5rem;
} }
.divider { .divider {
border: none; border: none;
border-top: 1px solid var(--cp-border); border-top: 1px solid var(--border, #1e1e1e);
margin: 0; margin: 0;
} }
@ -122,7 +105,7 @@ section {
font-size: 0.75rem; font-size: 0.75rem;
letter-spacing: 0.15em; letter-spacing: 0.15em;
text-transform: uppercase; text-transform: uppercase;
color: var(--cp-accent); color: var(--accent);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -140,8 +123,8 @@ section h2 {
} }
.card { .card {
background: var(--cp-bg2); background: var(--panel-bg);
border: 1px solid var(--cp-border); border: 1px solid var(--border, #1e1e1e);
border-radius: 10px; border-radius: 10px;
padding: 1.5rem; padding: 1.5rem;
} }
@ -154,7 +137,7 @@ section h2 {
.card p { .card p {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--cp-muted); color: var(--text-dim);
margin: 0; margin: 0;
line-height: 1.6; line-height: 1.6;
} }
@ -162,9 +145,9 @@ section h2 {
.cta { .cta {
text-align: center; text-align: center;
padding: 4rem 2rem; padding: 4rem 2rem;
background: var(--cp-accent-dim); background: color-mix(in srgb, var(--accent) 12%, transparent);
border-top: 1px solid var(--cp-border); border-top: 1px solid var(--border, #1e1e1e);
border-bottom: 1px solid var(--cp-border); border-bottom: 1px solid var(--border, #1e1e1e);
} }
.cta h2 { .cta h2 {
@ -174,7 +157,7 @@ section h2 {
} }
.cta p { .cta p {
color: var(--cp-muted); color: var(--text-dim);
margin: 0 auto 2rem; margin: 0 auto 2rem;
max-width: 480px; max-width: 480px;
} }
@ -182,7 +165,7 @@ section h2 {
.btn { .btn {
display: inline-block; display: inline-block;
padding: 0.75rem 2rem; padding: 0.75rem 2rem;
background: var(--cp-accent); background: var(--accent);
color: #fff; color: #fff;
border-radius: 6px; border-radius: 6px;
font-size: 0.9rem; font-size: 0.9rem;
@ -196,26 +179,13 @@ footer {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--cp-muted); color: var(--text-dim);
border-top: 1px solid var(--cp-border); border-top: 1px solid var(--border, #1e1e1e);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
} }
.footer-links {
display: flex;
justify-content: center;
gap: 1.5rem;
}
.footer-links a {
color: var(--cp-muted);
font-size: 0.8rem;
text-decoration: none;
}
.footer-links a:hover { color: var(--cp-text); }
@media (max-width: 600px) { @media (max-width: 600px) {
section { padding: 3rem 1.25rem; } section { padding: 3rem 1.25rem; }
.hero { padding: 4rem 1.25rem 3rem; } .hero { padding: 4rem 1.25rem 3rem; }

View File

@ -67,49 +67,32 @@
</p> </p>
<footer> <footer>
<div class="footer-links"> <span>© 2026 loop42 GmbH</span>
<router-link to="/impressum">Impressum</router-link>
<router-link to="/datenschutz">Datenschutz</router-link>
</div>
<span>© 2026 loop42 UG (haftungsbeschränkt)</span>
</footer> </footer>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.legal-page { .legal-page {
--cp-border: #1e1e1e;
--cp-muted: rgba(232,232,232,0.4);
--cp-accent: #7c6af7;
--cp-text: #e8e8e8;
padding: 3rem 2rem 2rem; padding: 3rem 2rem 2rem;
max-width: 720px; max-width: 720px;
margin: 0 auto; margin: 0 auto;
color: var(--cp-text); color: var(--text);
overflow-y: auto;
height: 100%;
} }
h1 { font-size: 2rem; font-weight: 300; margin-bottom: 2rem; } h1 { font-size: 2rem; font-weight: 300; margin-bottom: 2rem; }
h2 { font-size: 1.1rem; font-weight: 500; margin: 2rem 0 0.5rem; } h2 { font-size: 1.1rem; font-weight: 500; margin: 2rem 0 0.5rem; }
p, li { color: var(--cp-muted); font-size: 0.95rem; line-height: 1.7; } p, li { color: var(--text-dim); font-size: 0.95rem; line-height: 1.7; }
ul { padding-left: 1.5rem; } ul { padding-left: 1.5rem; }
a { color: var(--cp-accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
footer { footer {
text-align: center; text-align: center;
padding: 3rem 0 1rem; padding: 3rem 0 1rem;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--cp-muted); color: var(--text-dim);
border-top: 1px solid var(--cp-border); border-top: 1px solid var(--border, #1e1e1e);
margin-top: 3rem; margin-top: 3rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
} }
.footer-links { display: flex; justify-content: center; gap: 1.5rem; }
.footer-links a { color: var(--cp-muted); font-size: 0.8rem; }
.footer-links a:hover { color: var(--cp-text); }
</style> </style>

View File

@ -6,7 +6,7 @@
<h1>{{ THEME_NAMES[theme] }}</h1> <h1>{{ THEME_NAMES[theme] }}</h1>
<p class="home-sub">Don't Panic.</p> <p class="home-sub">Don't Panic.</p>
<RouterLink v-if="!isLoggedIn" to="/login" class="home-btn">Sign in </RouterLink> <RouterLink v-if="!isLoggedIn" to="/login" class="home-btn">Sign in </RouterLink>
<RouterLink v-else to="/agents" class="home-btn">Sign in </RouterLink> <RouterLink v-else to="/nyx" class="home-btn">Sign in </RouterLink>
</div> </div>
</div> </div>
</template> </template>

View File

@ -48,49 +48,32 @@
</p> </p>
<footer> <footer>
<div class="footer-links"> <span>© 2026 loop42 GmbH</span>
<router-link to="/impressum">Impressum</router-link>
<router-link to="/datenschutz">Datenschutz</router-link>
</div>
<span>© 2026 loop42 UG (haftungsbeschränkt)</span>
</footer> </footer>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.legal-page { .legal-page {
--cp-border: #1e1e1e;
--cp-muted: rgba(232,232,232,0.4);
--cp-accent: #7c6af7;
--cp-text: #e8e8e8;
padding: 3rem 2rem 2rem; padding: 3rem 2rem 2rem;
max-width: 720px; max-width: 720px;
margin: 0 auto; margin: 0 auto;
color: var(--cp-text); color: var(--text);
overflow-y: auto;
height: 100%;
} }
h1 { font-size: 2rem; font-weight: 300; margin-bottom: 2rem; } h1 { font-size: 2rem; font-weight: 300; margin-bottom: 2rem; }
h2 { font-size: 1.1rem; font-weight: 500; margin: 2rem 0 0.5rem; } h2 { font-size: 1.1rem; font-weight: 500; margin: 2rem 0 0.5rem; }
p, li { color: var(--cp-muted); font-size: 0.95rem; line-height: 1.7; } p, li { color: var(--text-dim); font-size: 0.95rem; line-height: 1.7; }
ul { padding-left: 1.5rem; } ul { padding-left: 1.5rem; }
a { color: var(--cp-accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
footer { footer {
text-align: center; text-align: center;
padding: 3rem 0 1rem; padding: 3rem 0 1rem;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--cp-muted); color: var(--text-dim);
border-top: 1px solid var(--cp-border); border-top: 1px solid var(--border, #1e1e1e);
margin-top: 3rem; margin-top: 3rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
} }
.footer-links { display: flex; justify-content: center; gap: 1.5rem; }
.footer-links a { color: var(--cp-muted); font-size: 0.8rem; }
.footer-links a:hover { color: var(--cp-text); }
</style> </style>

View File

@ -4,27 +4,23 @@
<div class="login-card"> <div class="login-card">
<!-- Already logged in --> <!-- Already logged in -->
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
<h2> Already signed in</h2> <h2>Signed in</h2>
<p class="login-info">You're connected. Head back to chat or sign out below.</p> <p class="login-info">You're connected.</p>
<button @click="router.push('/agents')">Go to Chat</button> <button @click="router.push('/nyx')">Go to Chat</button>
<button class="logout-btn" @click="doLogout(ws.disconnect)">Sign out</button> <button class="logout-btn" @click="doLogout(ws.disconnect)">Sign out</button>
</template> </template>
<!-- Login form --> <!-- Handling callback -->
<template v-else-if="loggingIn">
<h2>Signing in...</h2>
<p class="login-info">Exchanging credentials with auth server.</p>
</template>
<!-- Login prompt -->
<template v-else> <template v-else>
<h2>🔐 Sign in</h2> <h2>Sign in</h2>
<label class="login-label">Enter your Login Token</label> <p class="login-info">Sign in with your loop42 account.</p>
<input <button @click="doLogin" :disabled="loggingIn">Sign in</button>
v-model="loginToken"
type="password"
placeholder="Login Token"
@keyup.enter="doLogin"
:disabled="loggingIn"
autofocus
/>
<button @click="doLogin" :disabled="loggingIn">
{{ loggingIn ? 'Connecting...' : 'Connect' }}
</button>
<div v-if="loginError" class="login-error">{{ loginError }}</div> <div v-if="loginError" class="login-error">{{ loginError }}</div>
</template> </template>
</div> </div>
@ -32,8 +28,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue';
import router from '../router'; import router from '../router';
import { auth, ws } from '../store'; import { auth, ws } from '../store';
import WebGLBackground from '../components/WebGLBackground.vue'; import WebGLBackground from '../components/WebGLBackground.vue';
const { isLoggedIn, loginToken, loginError, loggingIn, doLogin, doLogout } = auth; const { isLoggedIn, loginError, loggingIn, doLogin, doLogout, handleCallback } = auth;
// Handle OIDC callback Zitadel redirects back with ?code=...
onMounted(async () => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
if (code && !isLoggedIn.value) {
// Clean URL before processing
window.history.replaceState({}, '', '/login');
await handleCallback(code);
}
});
</script> </script>

View File

@ -13,13 +13,16 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/ws': { '/ws': {
target: 'ws://localhost:8000', target: 'wss://assay.loop42.de',
ws: true, ws: true,
rewriteWsOrigin: true, rewriteWsOrigin: true,
secure: true,
changeOrigin: true,
}, },
'/api': { '/api': {
target: 'http://localhost:8000', target: 'https://assay.loop42.de',
changeOrigin: true, changeOrigin: true,
secure: true,
}, },
}, },
}, },