diff --git a/css/sidebar.css b/css/sidebar.css
index 771ebd5..b4c8941 100644
--- a/css/sidebar.css
+++ b/css/sidebar.css
@@ -3,7 +3,7 @@
:root {
--sidebar-width: 240px;
--sidebar-collapsed-width: 48px;
- --sidebar-header-height: 40px; /* aligns with content-top in views */
+ --sidebar-header-height: 40px;
}
/* ── App body: sidebar + content column ── */
@@ -42,34 +42,20 @@
z-index: 100;
}
-/* Fade text/indicators on expand/collapse */
-.sidebar-logo-label,
-.sidebar-room-name,
-.sidebar-segment-label,
-.sidebar-channel-indicators,
-.sidebar-room-mode-btn,
-.sidebar-chevron-btn,
-.sidebar-capture-btn,
+/* Fade text on collapse */
+.sidebar-brand-name,
.sidebar-link span,
.sidebar-user-name {
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-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-brand-name,
.app-sidebar.is-collapsed .sidebar-link span,
.app-sidebar.is-collapsed .sidebar-user-name {
opacity: 0;
- transition: opacity 0.05s ease; /* fade out fast */
+ transition: opacity 0.05s ease;
pointer-events: none;
- position: relative;
- z-index: 20;
}
.app-sidebar.is-collapsed {
@@ -81,12 +67,7 @@
pointer-events: auto;
}
-/* Legacy gradient shadow — replaced by panel box-shadow */
-.sidebar-shadow {
- display: none;
-}
-
-/* Invisible click target to close sidebar — behind sidebar content */
+/* Invisible click target to close sidebar */
.sidebar-close-target {
position: fixed;
inset: 0;
@@ -95,19 +76,15 @@
pointer-events: none;
}
-/* ── Sidebar header (logo + collapse) ── */
+/* ── Header (toggle + brand) ── */
.sidebar-header {
height: var(--sidebar-header-height);
display: flex;
align-items: center;
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 {
flex: 1;
display: flex;
@@ -118,15 +95,13 @@
color: var(--text);
text-decoration: none;
cursor: pointer;
- transition: opacity 0.15s;
+ transition: color 0.12s;
overflow: hidden;
white-space: nowrap;
- /* Offset for chevron width so brand is visually centered in sidebar */
- padding-right: var(--sidebar-collapsed-width);
+ padding-right: 36px; /* offset toggle btn width so brand is visually centered */
}
-.sidebar-brand:hover { opacity: 0.8; text-decoration: none; }
+.sidebar-brand:hover { color: var(--text); text-decoration: none; }
-/* Chevron rotation animation */
.sidebar-chevron-anim {
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@@ -145,10 +120,9 @@
white-space: nowrap;
}
-/* Toggle button: right side when expanded, centered when collapsed */
.sidebar-toggle-btn {
- width: var(--sidebar-collapsed-width);
- height: 100%;
+ width: 36px;
+ height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
@@ -157,194 +131,20 @@
border: none;
cursor: pointer;
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); }
-/* Icon slot: fixed 48px centered area — never moves on collapse */
-.sidebar-logo-img,
-.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;
+/* ── Nav links ── */
+.sidebar-nav {
display: flex;
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;
- overflow: hidden;
}
-/* Shared row style: icon in fixed 48px slot, text after */
+/* Unified link style — used for all sidebar items */
.sidebar-link {
display: flex;
align-items: center;
@@ -362,131 +162,51 @@
border: none;
width: 100%;
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.active { color: var(--accent); }
.sidebar-link svg {
flex-shrink: 0;
- /* Center 16px icon in 48px slot (minus 6px panel padding) */
width: 16px;
height: 16px;
margin-left: 10px;
margin-right: 10px;
}
+/* ── Spacer ── */
+.sidebar-flex-spacer {
+ flex: 1;
+}
-/* ── Channel state indicators (dual: private + public) ── */
-.sidebar-room { position: relative; }
-.sidebar-channel-indicators {
- position: absolute;
- right: 44px;
- top: 0;
+/* ── Footer (legal + user) ── */
+.sidebar-footer {
display: flex;
- gap: 3px;
- 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 {
+ flex-direction: column;
padding: 4px 0;
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 {
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 {
position: fixed;
inset: 0;
z-index: 150;
}
-/* ── Shared popup panel style ── */
-.sidebar-panel,
.sidebar-user-menu {
position: absolute;
bottom: 0;
@@ -496,57 +216,9 @@
background: var(--panel-bg);
border-radius: var(--radius-panel);
box-shadow: var(--panel-shadow);
- z-index: 200; /* above .sidebar-panel-backdrop (150) */
+ z-index: 200;
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 {
padding: 8px 12px 4px;
font-size: var(--text-base);
@@ -562,16 +234,11 @@
color: var(--text);
font-size: var(--text-base);
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); }
-/* Collapsed: smaller menu width */
-.app-sidebar.is-collapsed .sidebar-user-menu {
- width: 160px;
-}
-
-/* ── Sidebar spacer (reserves rail width on all screens) ── */
+/* ── Sidebar spacer (reserves rail width) ── */
.sidebar-spacer {
display: block;
width: var(--sidebar-collapsed-width);
@@ -580,36 +247,7 @@
flex: 0 0 var(--sidebar-collapsed-width);
}
-/* ── Version ── */
-.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 ── */
+/* ── Large screens: sidebar in flow ── */
@media (min-width: 1024px) {
.app-sidebar {
position: relative;
@@ -623,163 +261,14 @@
.app-sidebar:not(.is-collapsed) .sidebar-close-target {
pointer-events: none;
}
- /* Spacer matches sidebar width (not fixed 48px) */
.sidebar-spacer {
display: none;
}
}
-/* ── Mobile tweaks ── */
+/* ── Mobile ── */
@media (max-width: 480px) {
- /* Panels: constrain to viewport */
- .sidebar-panel,
.sidebar-user-menu {
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;
-}
-
diff --git a/src/App.vue b/src/App.vue
index d0ced29..bf37e40 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -9,14 +9,16 @@
+
@@ -28,7 +30,6 @@
+
+
diff --git a/src/composables/auth.ts b/src/composables/auth.ts
index c941bec..9974722 100644
--- a/src/composables/auth.ts
+++ b/src/composables/auth.ts
@@ -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';
-
-const SESSION_TOKEN_KEY = 'nyx_session';
-
import { getApiBase } from '../utils/apiBase';
-// Dev service token — auto-login for development
-const DEV_TOKEN = '7Oorb9S3OpwFyWgm4zi_Tq7GeamefbjjTgooPVPWAwPDOf6B4TvgvQlLbhmT4DjsqBS_D1g';
+const TOKEN_KEY = 'nyx_session';
+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
{
+ 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 {
+ 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) {
- // Auto-set dev token if no token exists
- if (!localStorage.getItem(SESSION_TOKEN_KEY) && !localStorage.getItem('titan_token')) {
- localStorage.setItem(SESSION_TOKEN_KEY, DEV_TOKEN);
- }
- const isLoggedIn: Ref = ref(!!localStorage.getItem(SESSION_TOKEN_KEY));
- const loginToken: Ref = ref('');
+ const isLoggedIn: Ref = ref(!!_getStoredToken());
const loginError: Ref = ref('');
const loggingIn: Ref = ref(false);
+ // Legacy compat — not used in PKCE flow but some UI may reference it
+ const loginToken: Ref = 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 {
- const token = loginToken.value.trim();
- if (!token) return;
loggingIn.value = true;
loginError.value = '';
+ await _fetchAuthConfig();
+
+ const verifier = _generateVerifier();
+ const challenge = await _generateChallenge(verifier);
+ sessionStorage.setItem(VERIFIER_KEY, verifier);
+
+ // Remember where to go after login
+ const returnPath = window.location.pathname;
+ if (returnPath !== '/login') {
+ sessionStorage.setItem(RETURN_KEY, returnPath);
+ }
+
+ const params = new URLSearchParams({
+ response_type: 'code',
+ client_id: _clientId,
+ 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 {
+ 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 nonceRes = await fetch(`${getApiBase()}/api/auth/nonce`);
- if (!nonceRes.ok) { loginError.value = 'Auth unavailable'; loggingIn.value = false; return; }
- const { nonce } = await nonceRes.json();
- const res = await fetch(`${getApiBase()}/api/auth`, {
+ const res = await fetch(`${_issuer}/oauth/v2/token`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ token, nonce }),
+ 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(() => ({ error: 'Login failed' }));
- loginError.value = err.error || 'Invalid token';
+ const err = await res.json().catch(() => ({}));
+ loginError.value = err.error_description || err.error || 'Token exchange failed';
loggingIn.value = false;
- return;
+ return false;
}
- const { sessionToken } = await res.json();
- localStorage.removeItem('titan_token');
- localStorage.removeItem('nyx_token');
- localStorage.setItem(SESSION_TOKEN_KEY, sessionToken);
- sessionStorage.removeItem('agent');
+
+ 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;
connectFn();
- router.push('/chat');
- setTimeout(() => { loggingIn.value = false; }, 500);
- } catch {
- loginError.value = 'Network error';
- loggingIn.value = false;
}
}
async function doLogout(disconnectFn?: () => void): Promise {
- 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();
- localStorage.removeItem(SESSION_TOKEN_KEY);
- localStorage.removeItem('titan_token');
- localStorage.removeItem('nyx_token');
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.removeItem('nyx_user');
+ sessionStorage.removeItem('nyx_ws_session');
sessionStorage.removeItem('agent');
- sessionStorage.removeItem('viewer_auth'); // clear cached fstoken
isLoggedIn.value = false;
- loginToken.value = '';
loggingIn.value = false;
router.push('/');
}
- return { isLoggedIn, loginToken, loginError, loggingIn, doLogin, doLogout };
+ return { isLoggedIn, loginToken, loginError, loggingIn, currentUser, doLogin, doLogout, handleCallback, tryAutoConnect };
}
diff --git a/src/composables/useBreakout.ts b/src/composables/useBreakout.ts
index 41d330b..c9d24cf 100644
--- a/src/composables/useBreakout.ts
+++ b/src/composables/useBreakout.ts
@@ -56,7 +56,7 @@ export function useBreakout() {
pendingRequest.value = null;
if (!confirmed) { resolve({ error: 'rejected by user' }); return; }
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`);
if (!popup) { resolve({ error: 'popup blocked' }); return; }
windows.set(name, popup);
@@ -70,7 +70,7 @@ export function useBreakout() {
function openDirect(name: string, presetStr: string, parentToken: string) {
const [w, h] = presetStr.split('x').map(Number);
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`);
if (!popup) { alert('Popup blocked -- allow popups for this site'); return; }
windows.set(name, popup);
diff --git a/src/composables/ws.ts b/src/composables/ws.ts
index e714835..f4c284c 100644
--- a/src/composables/ws.ts
+++ b/src/composables/ws.ts
@@ -1,8 +1,12 @@
/**
- * ws.ts — WebSocket transport layer
+ * ws.ts — Streamable HTTP transport layer
*
- * Pure connection management: connect, disconnect, reconnect, send, onMessage.
- * Delegates takeover commands to useTakeover.
+ * Replaced WebSocket with HTTP POST + SSE streaming.
+ * 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.
* 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 ──
const H = useHermes();
-// WebSocket + ping timer
-let _ws: WebSocket | null = H.ws ?? null;
-let _pingInterval: ReturnType | null = H.wsPing ?? null;
// Callbacks + buffer
const _onMessageCallbacks: ((data: any) => void)[] = H.wsCbs ?? [];
const _messageBuffer: any[] = H.wsBuf ?? [];
-// Reconnect
-let _reconnectTimer: ReturnType | 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)
-let _selectedAgentRef: Ref | null = null;
-let _selectedModeRef: Ref | null = null;
let _isLoggedInRef: Ref | null = null;
let _loginErrorRef: Ref | null = null;
let _takeover: ReturnType | 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 | null = null;
const connected = H.wsConnected ?? ref(false);
const status = H.wsStatus ?? ref('Disconnected');
@@ -54,8 +54,25 @@ H.wsInit = isInitialLoad;
H.wsCbs = _onMessageCallbacks;
H.wsBuf = _messageBuffer;
-function send(payload: MessagePayload): void {
- if (_ws && _ws.readyState === WebSocket.OPEN) _ws.send(JSON.stringify(payload));
+function getToken(): string {
+ 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() {
@@ -63,132 +80,278 @@ function getTakeover() {
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 {
+ if (_healthAbort) _healthAbort.abort();
+ _healthAbort = new AbortController();
+ const base = getApiBase();
+ const token = getToken();
-function getWsUrl(): string {
- let base: string;
- if (import.meta.env.VITE_WS_URL) {
- base = import.meta.env.VITE_WS_URL as string;
- } else {
- const params = new URLSearchParams(window.location.search);
- const wsHost = params.get('ws') || window.location.hostname;
- const wsPort = params.get('port') || window.location.port;
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- base = `${protocol}//${wsHost}:${wsPort}/ws`;
+ try {
+ const res = await fetch(`${base}/api/health-stream`, {
+ headers: { 'Authorization': `Bearer ${token}` },
+ signal: _healthAbort.signal,
+ });
+ if (!res.ok) return;
+
+ const reader = res.body!.getReader();
+ const decoder = new TextDecoder();
+ const wasDisconnected = !connected.value;
+ connected.value = true;
+ status.value = 'Connected';
+ if (wasDisconnected) {
+ _dispatch({ type: 'connection_state', state: 'SYNCED' });
+ }
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const text = decoder.decode(value, { stream: true });
+ if (text.includes('heartbeat')) {
+ connected.value = true;
+ status.value = 'Connected';
+ }
+ }
+ } catch (e: any) {
+ if (e.name === 'AbortError') return;
}
- // Append token + session as query params (assay auth pattern)
- const token = localStorage.getItem('nyx_session') || localStorage.getItem('titan_token') || DEV_TOKEN;
- const session = sessionStorage.getItem('nyx_ws_session') || '';
- const qs = new URLSearchParams();
- if (token) qs.set('token', token);
- if (session) qs.set('session', session);
- return qs.toString() ? `${base}?${qs}` : base;
+
+ // 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);
}
-function scheduleReconnect() {
- if (_reconnectTimer) return;
- if (!_isLoggedInRef?.value) return;
- status.value = `Reconnecting in ${Math.round(_reconnectDelay / 1000)}s…`;
- _reconnectTimer = setTimeout(() => {
- _reconnectTimer = null;
- if (_isLoggedInRef?.value) connect(_selectedAgentRef!, _isLoggedInRef!, _loginErrorRef!);
- }, _reconnectDelay);
- _reconnectDelay = Math.min(_reconnectDelay * 2, 16000);
-}
-
-function connect(
+/**
+ * Connect: fetch or create session via GET /api/session, then mark connected.
+ */
+async function connect(
selectedAgent: Ref,
isLoggedInRef: Ref,
loginErrorRef: Ref,
- selectedMode?: Ref
-): void {
- if (_ws && _ws.readyState <= WebSocket.OPEN) return;
- _selectedAgentRef = selectedAgent;
- _selectedModeRef = selectedMode ?? null;
+ _selectedMode?: Ref
+): Promise {
_isLoggedInRef = isLoggedInRef;
_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) {
status.value = 'Connecting...';
isInitialLoad.value = false;
}
- _ws = new WebSocket(wsUrl);
- H.ws = _ws;
+ const base = getApiBase();
+ 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 {
- const data = JSON.parse(event.data);
- if (data.type === 'dev_cmd' && data.cmdId && data.cmd) {
- getTakeover().dispatch(data.cmdId, data.cmd, data.args || {}, send);
- return;
- }
- // 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) {
+ try {
+ const res = await fetch(`${base}/api/session?${qs}`, {
+ headers: { 'Authorization': `Bearer ${token}` },
+ });
+ if (res.status === 401) {
isLoggedInRef.value = false;
loginErrorRef.value = 'Session expired. Please log in again.';
localStorage.removeItem('nyx_session');
localStorage.removeItem('titan_token');
- sessionStorage.removeItem('agent');
status.value = 'Logged out';
- // Redirect to login — avoids broken UI with stale state
if (window.location.pathname !== '/login') {
window.location.pathname = '/login';
}
- } else {
- scheduleReconnect();
+ return;
}
+ 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 = {
+ 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, signal: AbortSignal): Promise {
+ 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 {
+ 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 {
+ 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 {
- if (_pingInterval) { clearInterval(_pingInterval); _pingInterval = null; H.wsPing = null; }
- if (_ws) {
- _ws.onclose = null;
- _ws.close();
- _ws = null;
- H.ws = null;
- }
+ if (_chatAbort) { _chatAbort.abort(); _chatAbort = null; }
+ if (_healthAbort) { _healthAbort.abort(); _healthAbort = null; }
+ if (_healthReconnectTimer) { clearTimeout(_healthReconnectTimer); _healthReconnectTimer = null; }
connected.value = false;
status.value = 'Disconnected';
currentUser.value = '';
@@ -196,23 +359,9 @@ function disconnect(): void {
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 | 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 {
_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 {
@@ -231,6 +380,9 @@ function replayBuffer(fn: (data: any) => void): void {
_messageBuffer.forEach(data => fn(data));
}
+// Backward compat — not needed for HTTP but some code may reference it
+function sendDeferredAuth(): void {}
+
export function useWebSocket() {
return {
connected, status, currentUser, sessionId,
diff --git a/src/main.ts b/src/main.ts
index 541c92d..aaaa578 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -28,7 +28,6 @@ import '../css/markdown.css';
import '../css/views/agents.css';
import '../css/views/home.css';
import '../css/views/login.css';
-import '../css/views/dev.css';
import App from './App.vue';
import router from './router';
diff --git a/src/router.ts b/src/router.ts
index 9c4a67e..fde05dc 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -9,14 +9,25 @@ const router = createRouter({
{ 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: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } },
- { path: '/agents', name: 'agents', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'Home', requiresSocket: true } },
- { path: '/chat', redirect: '/agents' },
- { path: '/dev', name: 'dev', component: () => import('./views/DevView.vue'), meta: { suffix: 'Dev', requiresSocket: true } },
- { path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresSocket: true } },
+ { path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } },
+ { path: '/agents', redirect: '/nyx' },
+ { path: '/chat', redirect: '/nyx' },
+ { path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresAuth: true } },
{ 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) => {
const { theme } = useTheme();
const brand = THEME_NAMES[theme.value] || 'loop42';
diff --git a/src/store.ts b/src/store.ts
index 6c9c763..f1f0cbf 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -28,12 +28,14 @@ export const takeover = ws.getTakeover();
export const agents = useAgents(ws.connected);
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(() => {
ws.connect(agents.selectedAgent, auth.isLoggedIn, auth.loginError, agents.selectedMode);
// Pre-warm viewer token in background so agent→viewer is instant
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
export { useViewerStore };
diff --git a/src/views/AgentsView.vue b/src/views/AgentsView.vue
index 329e9e6..844c306 100644
--- a/src/views/AgentsView.vue
+++ b/src/views/AgentsView.vue
@@ -239,15 +239,8 @@ import { getApiBase } from '../utils/apiBase';
const agentsRoute = useRoute();
const chatStore = useChatStore();
-// Show picker when route has no ?agent= param (overview mode)
-const showPicker = computed(() => !agentsRoute.query.agent);
-
-// 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 });
+// No picker — always show chat.
+const showPicker = computed(() => false);
// Previous sessions (server-fetched, supports load-more)
interface PrevSession { messages: any[]; timestamp: string | null; timeLabel: string }
const prevSessions = ref([]);
@@ -316,7 +309,7 @@ function pickAgent(agentId: string, mode: string) {
sessionStorage.setItem('agent', agentId);
sessionStorage.setItem('agent_mode', mode);
// 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) {
ws.switchAgent(agentId, mode);
} else {
@@ -326,6 +319,18 @@ function pickAgent(agentId: string, mode: string) {
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 controlsEl = ref(null);
@@ -374,7 +379,7 @@ if (typeof window !== 'undefined') {
(window as any).__toolCallMap = toolCallMapSnapshot;
}
-const viewActive = computed(() => agentsRoute.name === 'agents');
+const viewActive = computed(() => agentsRoute.name === 'nyx');
// --- Scroll logic ---
// On user send: scroll so user message sits at top of viewport.
diff --git a/src/views/CompanyView.vue b/src/views/CompanyView.vue
index e3ef9f2..5f9c396 100644
--- a/src/views/CompanyView.vue
+++ b/src/views/CompanyView.vue
@@ -5,7 +5,6 @@
Eigene Plattform. Eigene Infrastruktur. Eigene Regeln.
-
-
Produkte
@@ -43,37 +41,22 @@
-
nyx
Produkte kennenlernen, ausprobieren, Zugang einrichten — direkt hier, direkt mit nyx.
-
nyx öffnen
+
nyx öffnen
diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue
index ac145bd..7936440 100644
--- a/src/views/HomeView.vue
+++ b/src/views/HomeView.vue
@@ -6,7 +6,7 @@
{{ THEME_NAMES[theme] }}
Don't Panic.
Sign in →
- Sign in →
+ Sign in →
diff --git a/src/views/ImpressumView.vue b/src/views/ImpressumView.vue
index 77c5328..57b958c 100644
--- a/src/views/ImpressumView.vue
+++ b/src/views/ImpressumView.vue
@@ -48,49 +48,32 @@
diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue
index a62e575..84aad83 100644
--- a/src/views/LoginView.vue
+++ b/src/views/LoginView.vue
@@ -4,27 +4,23 @@
- ✅ Already signed in
- You're connected. Head back to chat or sign out below.
-
+ Signed in
+ You're connected.
+
-
+
+
+ Signing in...
+ Exchanging credentials with auth server.
+
+
+
- 🔐 Sign in
-
-
-
+ Sign in
+ Sign in with your loop42 account.
+
{{ loginError }}
@@ -32,8 +28,20 @@
diff --git a/vite.config.ts b/vite.config.ts
index e4b5abe..857fba2 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -13,13 +13,16 @@ export default defineConfig({
port: 5173,
proxy: {
'/ws': {
- target: 'ws://localhost:8000',
+ target: 'wss://assay.loop42.de',
ws: true,
rewriteWsOrigin: true,
+ secure: true,
+ changeOrigin: true,
},
'/api': {
- target: 'http://localhost:8000',
+ target: 'https://assay.loop42.de',
changeOrigin: true,
+ secure: true,
},
},
},