feat(theme): per-tenant dark/bright themes, brand decoupled from mode
- TenantConfig gains themes: { dark, bright } — CSS data-theme names per mode
- tenants/loop42/theme.css: dark + bright CSS blocks (moved out of base.css)
- tenants/dev/theme.css: titan-bright CSS block (titan dark stays as :root)
- useTheme: stores 'dark'|'bright' in nyx_mode, toggles via tenant themes config
- AppToolbar: single sun/moon toggle replaces multi-button brand switcher
- AppSidebar, HomeView, router: brand name/icon from tenant.name directly, not theme
- themeSwitcher feature flag removed (replaced by universal toggle)
- Fix: local K3s auth mismatch — VITE_AUTH_DISABLED removed from .env.local;
infra/k8s/local/runtime.yaml sets AUTH_ENABLED=false to match test namespace
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cca0bb8bcd
commit
599c5132a5
@ -1,9 +1,11 @@
|
||||
FROM node:22-alpine AS build
|
||||
ARG TENANT=loop42
|
||||
ARG AUTH_DISABLED=false
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ENV VITE_AUTH_DISABLED=$AUTH_DISABLED
|
||||
RUN npx vite build --mode $TENANT
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
31
css/base.css
31
css/base.css
@ -147,37 +147,6 @@ body {
|
||||
--font-sans: 'Raleway', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
/* ── Workhorse theme (neutral dark + 6 happy kids colors) ─── */
|
||||
/* Palette: #73B96E #46915C #F25E44 #489FB2 #FFD96C #C895C0 */
|
||||
/* Backgrounds: neutral grays, not from palette */
|
||||
[data-theme="loop42"] {
|
||||
--bg: #1A212C;
|
||||
--surface: #222a36;
|
||||
--border: #1D7872;
|
||||
--chat-bg: #1e2630;
|
||||
--agent: #222a36;
|
||||
--agent-border:#1D7872;
|
||||
--muted: #2a3340;
|
||||
--muted-text: #71B095;
|
||||
|
||||
--text: #e8e6e0;
|
||||
--text-dim: #71B095;
|
||||
|
||||
--accent: #1D7872; /* deep teal — grounded */
|
||||
--user-bubble: #2a3340;
|
||||
--primary: #71B095; /* sage green — links */
|
||||
--btn-bg: #1D7872;
|
||||
--btn-text: #e8e6e0;
|
||||
--btn-hover: #71B095;
|
||||
|
||||
--input-bg: #222a36;
|
||||
--input-border:#1D7872;
|
||||
--input-text: #e8e6e0;
|
||||
|
||||
/* Panel system */
|
||||
--panel-bg: #1e2630;
|
||||
--panel-shadow: 0 2px 12px rgba(0,0,0,0.35), 0 0 1px rgba(29,120,114,0.15);
|
||||
}
|
||||
|
||||
/* Scrollbars — OverlayScrollbars handles main containers.
|
||||
Fallback for any native scrollbar we missed. */
|
||||
|
||||
1101
package-lock.json
generated
1101
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,14 +5,19 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"happy-dom": "^20.8.9",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.2",
|
||||
"vue-tsc": "^3.2.5"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@ -14,8 +14,8 @@
|
||||
</button>
|
||||
<RouterLink v-if="isOpen" to="/" class="sidebar-brand" title="Home" @click="collapse">
|
||||
<img v-if="navLogo" :src="navLogo" class="sidebar-brand-logo" alt="Home" />
|
||||
<component v-else :is="THEME_ICONS[theme]" class="sidebar-brand-icon" />
|
||||
<span class="sidebar-brand-name">{{ THEME_NAMES[theme] }}</span>
|
||||
<component v-else :is="brandIcon" class="sidebar-brand-icon" />
|
||||
<span class="sidebar-brand-name">{{ tenant.name }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
@ -90,15 +90,18 @@ import {
|
||||
HomeIcon,
|
||||
BeakerIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import { THEME_ICONS, THEME_LOGOS, THEME_NAMES, useTheme } from '../composables/useTheme';
|
||||
import { THEME_ICONS, THEME_LOGOS } from '../composables/useTheme';
|
||||
import type { Theme } from '../composables/useTheme';
|
||||
import { auth } from '../store';
|
||||
import tenant from '../tenant';
|
||||
|
||||
const emit = defineEmits<{ logout: [] }>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const navLogo = computed(() => THEME_LOGOS[theme.value]);
|
||||
const brandTheme = tenant.themes.dark as Theme;
|
||||
const brandIcon = THEME_ICONS[brandTheme];
|
||||
const navLogo = THEME_LOGOS[brandTheme];
|
||||
|
||||
const { isLoggedIn, currentUser } = auth;
|
||||
|
||||
|
||||
@ -30,10 +30,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Group: Themes -->
|
||||
<!-- Group: Bright/dark toggle -->
|
||||
<div class="toolbar-group">
|
||||
<button v-for="t in THEMES" :key="t" class="toolbar-btn" :class="{ active: theme === t }" @click="setTheme(t)" :title="THEME_NAMES[t]">
|
||||
<component :is="THEME_ICONS[t]" class="w-6 h-6" />
|
||||
<button class="toolbar-btn" :class="{ active: !isDark }" @click="toggleMode" :title="isDark ? 'Switch to bright' : 'Switch to dark'">
|
||||
<SunIcon v-if="!isDark" class="w-6 h-6" />
|
||||
<MoonIcon v-else class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -81,20 +82,17 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'AppToolbar' });
|
||||
import { ref, computed, type Component } from 'vue';
|
||||
import { WifiIcon, SignalIcon, QueueListIcon, CpuChipIcon, EyeIcon, CircleStackIcon, ChatBubbleLeftIcon, RectangleGroupIcon, FolderIcon, SparklesIcon } from '@heroicons/vue/20/solid';
|
||||
import { THEME_ICONS, THEME_NAMES, useTheme, type Theme } from '../composables/useTheme';
|
||||
import { WifiIcon, SignalIcon, QueueListIcon, CpuChipIcon, EyeIcon, CircleStackIcon, ChatBubbleLeftIcon, RectangleGroupIcon, FolderIcon, SparklesIcon, SunIcon, MoonIcon } from '@heroicons/vue/20/solid';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
import { ws, auth, takeover } from '../store';
|
||||
import { useChatStore } from '../store/chat';
|
||||
import { useUI } from '../composables/ui';
|
||||
import { usePanels, type PanelId } from '../composables/usePanels';
|
||||
import { injectToolbar } from '../composables/useToolbar';
|
||||
|
||||
const THEMES: Theme[] = ['loop42', 'titan', 'eras'];
|
||||
|
||||
const { isLoggedIn } = auth;
|
||||
const { theme, setTheme } = useTheme();
|
||||
const chatStore = useChatStore();
|
||||
const { availablePanels, togglePanel: togglePanelBtn, isPanelOpen, togglePane, isPaneOpen } = usePanels();
|
||||
const { isDark, toggleMode } = useTheme();
|
||||
|
||||
// Toolbar config — set by active view via provideToolbar()
|
||||
const toolbar = injectToolbar();
|
||||
|
||||
@ -96,9 +96,9 @@ onBeforeUnmount(() => {
|
||||
.content-layout.col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.content-layout.col .slot-chat { flex: 40 1 0; border-bottom: 1px solid var(--border); }
|
||||
.content-layout.col .slot-dashboard { flex: 40 1 0; border-bottom: 1px solid var(--border); }
|
||||
.content-layout.col .slot-thin { flex: 10 1 0; display: flex; flex-direction: row; }
|
||||
.content-layout.col .slot-chat { flex: 40 1 0; border-bottom: 1px solid var(--border); order: 2; }
|
||||
.content-layout.col .slot-dashboard { flex: 40 1 0; border-bottom: 1px solid var(--border); order: 1; }
|
||||
.content-layout.col .slot-thin { flex: 10 1 0; display: flex; flex-direction: row; order: 3; }
|
||||
.content-layout.col .slot-files { flex: 1 1 0; border-right: 1px solid var(--border); }
|
||||
.content-layout.col .slot-artifacts { flex: 1 1 0; }
|
||||
|
||||
|
||||
@ -56,7 +56,8 @@ function _isTokenExpired(token: string): boolean {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return payload.exp * 1000 < Date.now();
|
||||
} catch {
|
||||
return true;
|
||||
// Non-JWT tokens (service tokens) are not considered expired
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -202,6 +203,15 @@ export function useAuth(connectFn: () => void) {
|
||||
* Auto-connect if we have a valid token on page load.
|
||||
*/
|
||||
function tryAutoConnect(): void {
|
||||
// Auth disabled (test/headless mode) — auto-login with any token or a dummy
|
||||
if (import.meta.env.VITE_AUTH_DISABLED === 'true') {
|
||||
if (!localStorage.getItem(TOKEN_KEY)) {
|
||||
localStorage.setItem(TOKEN_KEY, 'test-token');
|
||||
}
|
||||
isLoggedIn.value = true;
|
||||
connectFn();
|
||||
return;
|
||||
}
|
||||
if (_getStoredToken()) {
|
||||
isLoggedIn.value = true;
|
||||
connectFn();
|
||||
|
||||
@ -82,7 +82,7 @@ const openPaneIds = ref<Set<PaneId>>(loadSet<PaneId>(PANES_KEY, ['chat', 'dashbo
|
||||
export function usePanels() {
|
||||
// Current role
|
||||
const role = computed<UserRole>(() => {
|
||||
if (tenant.features.devTools) return 'dev';
|
||||
if (tenant.features.devTools || import.meta.env.DEV) return 'dev';
|
||||
return 'user';
|
||||
});
|
||||
|
||||
|
||||
@ -2,52 +2,71 @@ import { ref, watch, type Component } from 'vue';
|
||||
import { CommandLineIcon, SunIcon, CubeTransparentIcon } from '@heroicons/vue/24/outline';
|
||||
import tenant from '../tenant';
|
||||
|
||||
export type Theme = 'titan' | 'eras' | 'loop42';
|
||||
export type Theme = 'titan' | 'titan-bright' | 'eras' | 'eras-bright' | 'loop42' | 'loop42-bright';
|
||||
type Mode = 'dark' | 'bright';
|
||||
|
||||
const STORAGE_KEY = 'hermes_theme';
|
||||
const STORAGE_KEY = 'nyx_mode';
|
||||
|
||||
/** Heroicon component per theme — used in nav, home page, and theme buttons */
|
||||
/** Heroicon component per theme — used in nav, home page */
|
||||
export const THEME_ICONS: Record<Theme, Component> = {
|
||||
titan: CommandLineIcon,
|
||||
'titan-bright': CommandLineIcon,
|
||||
eras: SunIcon,
|
||||
'eras-bright': SunIcon,
|
||||
loop42: CubeTransparentIcon,
|
||||
'loop42-bright': CubeTransparentIcon,
|
||||
};
|
||||
|
||||
/** Display name per theme */
|
||||
export const THEME_NAMES: Record<Theme, string> = {
|
||||
titan: 'Titan',
|
||||
'titan-bright': 'Titan',
|
||||
eras: 'ERAS',
|
||||
'eras-bright': 'ERAS',
|
||||
loop42: 'loop42',
|
||||
'loop42-bright': 'loop42',
|
||||
};
|
||||
|
||||
/** Optional external logo per theme (e.g. customer branding). Null = use THEME_ICONS. */
|
||||
export const THEME_LOGOS: Record<Theme, string | null> = {
|
||||
titan: null,
|
||||
eras: null,
|
||||
loop42: null,
|
||||
titan: null, 'titan-bright': null,
|
||||
eras: null, 'eras-bright': null,
|
||||
loop42: null, 'loop42-bright': null,
|
||||
};
|
||||
|
||||
// Map agent id → theme (unlisted agents default to 'titan')
|
||||
// Map agent id → theme (unlisted agents default to tenant dark theme)
|
||||
export const AGENT_THEME_MAP: Record<string, Theme> = {
|
||||
eras: 'eras',
|
||||
};
|
||||
|
||||
export function agentLogo(agentId: string): string | null {
|
||||
const t = AGENT_THEME_MAP[agentId] ?? 'titan';
|
||||
const t = AGENT_THEME_MAP[agentId] ?? (tenant.themes.dark as Theme);
|
||||
return THEME_LOGOS[t];
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const defaultTheme = (tenant.defaultTheme || 'loop42') as Theme;
|
||||
const theme = ref<Theme>(
|
||||
stored === 'workhorse' ? 'loop42' : // migrate legacy name
|
||||
(stored as Theme) || defaultTheme
|
||||
);
|
||||
// Migrate from legacy hermes_theme storage
|
||||
function migrateMode(): Mode {
|
||||
const legacy = localStorage.getItem('hermes_theme');
|
||||
if (legacy) {
|
||||
localStorage.removeItem('hermes_theme');
|
||||
const bright = legacy === 'eras' || legacy.includes('bright');
|
||||
return bright ? 'bright' : 'dark';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
const storedMode = localStorage.getItem(STORAGE_KEY) as Mode | null;
|
||||
const initMode: Mode = storedMode ?? migrateMode();
|
||||
const isDark = ref<boolean>(initMode === 'dark');
|
||||
|
||||
function activeTheme(): Theme {
|
||||
return (isDark.value ? tenant.themes.dark : tenant.themes.bright) as Theme;
|
||||
}
|
||||
|
||||
const THEME_FAVICONS: Record<Theme, string> = {
|
||||
titan: '/favicon-titan.svg',
|
||||
eras: '/favicon-eras.svg',
|
||||
loop42: '/favicon-loop42.svg',
|
||||
titan: '/favicon-titan.svg', 'titan-bright': '/favicon-titan.svg',
|
||||
eras: '/favicon-eras.svg', 'eras-bright': '/favicon-eras.svg',
|
||||
loop42: '/favicon-loop42.svg', 'loop42-bright': '/favicon-loop42.svg',
|
||||
};
|
||||
|
||||
function applyTheme(t: Theme) {
|
||||
@ -56,20 +75,24 @@ function applyTheme(t: Theme) {
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
}
|
||||
// Update favicon
|
||||
const link = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
|
||||
if (link) link.href = THEME_FAVICONS[t] || '/favicon.svg';
|
||||
}
|
||||
|
||||
// theme ref — the active CSS theme name (for callers using THEME_ICONS/THEME_NAMES)
|
||||
const theme = ref<Theme>(activeTheme());
|
||||
|
||||
// Apply on init
|
||||
applyTheme(theme.value);
|
||||
|
||||
watch(theme, (t) => {
|
||||
watch(isDark, () => {
|
||||
const t = activeTheme();
|
||||
theme.value = t;
|
||||
applyTheme(t);
|
||||
localStorage.setItem(STORAGE_KEY, t);
|
||||
localStorage.setItem(STORAGE_KEY, isDark.value ? 'dark' : 'bright');
|
||||
});
|
||||
|
||||
export function useTheme() {
|
||||
function setTheme(t: Theme) { theme.value = t; }
|
||||
return { theme, setTheme };
|
||||
function toggleMode() { isDark.value = !isDark.value; }
|
||||
return { theme, isDark, toggleMode };
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ let _chatAbort: AbortController | null = null;
|
||||
// Health stream abort + reconnect
|
||||
let _healthAbort: AbortController | null = null;
|
||||
let _healthReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _healthRetryDelay = 3000; // starts at 3s, backs off to 15s
|
||||
|
||||
const connected = H.wsConnected ?? ref(false);
|
||||
const status = H.wsStatus ?? ref('Disconnected');
|
||||
@ -76,7 +77,15 @@ function _dispatch(data: any) {
|
||||
}
|
||||
|
||||
function getTakeover() {
|
||||
if (!_takeover) _takeover = useTakeover(send);
|
||||
if (!_takeover) {
|
||||
_takeover = useTakeover(send);
|
||||
// Expose dispatch for Chrome extension forwarding (window.__hermes._takeoverDispatch)
|
||||
(useHermes() as any)._takeoverDispatch = (cmd: string, args: any, resolve: (r: any) => void) => {
|
||||
_takeover!.dispatch('ext-fwd', cmd, args, (msg: any) => {
|
||||
resolve(msg.result !== undefined ? msg.result : (msg.error ? { error: msg.error } : null));
|
||||
});
|
||||
};
|
||||
}
|
||||
return _takeover;
|
||||
}
|
||||
|
||||
@ -95,13 +104,17 @@ async function _startHealthStream(): Promise<void> {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
signal: _healthAbort.signal,
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
// Server up but returned error — retry after backoff
|
||||
throw new Error(`health-stream ${res.status}`);
|
||||
}
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const wasDisconnected = !connected.value;
|
||||
connected.value = true;
|
||||
status.value = 'Connected';
|
||||
_resetHealthBackoff();
|
||||
if (wasDisconnected) {
|
||||
_dispatch({ type: 'connection_state', state: 'SYNCED' });
|
||||
}
|
||||
@ -135,14 +148,19 @@ async function _startHealthStream(): Promise<void> {
|
||||
|
||||
// Stream ended or errored — server gone
|
||||
connected.value = false;
|
||||
status.value = 'Disconnected';
|
||||
status.value = 'Reconnecting...';
|
||||
_dispatch({ type: 'connection_state', state: 'CONNECTING' });
|
||||
// Auto-reconnect after 3s
|
||||
// Auto-reconnect with backoff (3s → 6s → 12s → 15s max)
|
||||
if (_healthReconnectTimer) clearTimeout(_healthReconnectTimer);
|
||||
_healthReconnectTimer = setTimeout(() => {
|
||||
_healthReconnectTimer = null;
|
||||
_healthRetryDelay = Math.min(_healthRetryDelay * 2, 15000);
|
||||
if (_isLoggedInRef?.value !== false) _startHealthStream();
|
||||
}, 3000);
|
||||
}, _healthRetryDelay);
|
||||
}
|
||||
|
||||
function _resetHealthBackoff() {
|
||||
_healthRetryDelay = 3000;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
10
src/main.ts
10
src/main.ts
@ -20,6 +20,8 @@ import { createPinia } from 'pinia';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import '../css/tailwind.css';
|
||||
import '../css/base.css';
|
||||
import '../tenants/loop42/theme.css';
|
||||
import '../tenants/dev/theme.css';
|
||||
import '../css/scrollbar.css';
|
||||
import '../css/layout.css';
|
||||
import '../css/sidebar.css';
|
||||
@ -37,6 +39,12 @@ app.use(pinia);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV || import.meta.env.VITE_AUTH_DISABLED === 'true') {
|
||||
(window as any).__pinia = pinia;
|
||||
// Expose ws.send for Playwright test automation (deferred so store.ts initializes first)
|
||||
import('./store').then(({ ws }) => {
|
||||
(window as any).__nyxSend = (content: string) => {
|
||||
ws.send({ type: 'message', content });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import LoginView from './views/LoginView.vue';
|
||||
import { THEME_NAMES, useTheme } from './composables/useTheme';
|
||||
import tenant from './tenant';
|
||||
import { HomePage, ImpressumPage, DatenschutzPage } from './tenantPages';
|
||||
|
||||
@ -12,6 +11,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: '/nyx', name: 'nyx', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'nyx', requiresAuth: true } },
|
||||
{ path: '/agents', redirect: '/nyx' },
|
||||
{ path: '/chat', redirect: '/nyx' },
|
||||
{ path: '/tests', name: 'tests', component: () => import('./views/TestsView.vue'), meta: { suffix: 'Tests' } },
|
||||
];
|
||||
|
||||
if (tenant.features.viewer) {
|
||||
@ -27,6 +27,7 @@ const router = createRouter({
|
||||
|
||||
// Auth guard — redirect to /login for protected routes
|
||||
router.beforeEach((to) => {
|
||||
if (import.meta.env.VITE_AUTH_DISABLED === 'true') return; // test mode
|
||||
if (to.meta?.requiresAuth) {
|
||||
const token = localStorage.getItem('nyx_session');
|
||||
if (!token) {
|
||||
@ -37,10 +38,8 @@ router.beforeEach((to) => {
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
const { theme } = useTheme();
|
||||
const brand = THEME_NAMES[theme.value] || 'loop42';
|
||||
const suffix = (to.meta?.suffix as string) || '';
|
||||
document.title = suffix ? `${brand} - ${suffix}` : brand;
|
||||
document.title = suffix ? `${tenant.name} - ${suffix}` : tenant.name;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
128
src/store/chat.test.ts
Normal file
128
src/store/chat.test.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { useChatStore } from './chat';
|
||||
|
||||
describe('useChatStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe('artifacts', () => {
|
||||
it('starts with empty artifacts', () => {
|
||||
const store = useChatStore();
|
||||
expect(store.artifacts).toEqual([]);
|
||||
expect(store.displayArtifacts).toEqual([]);
|
||||
expect(store.konsoleArtifacts).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters display artifacts (data_table, document_page, entity_detail)', () => {
|
||||
const store = useChatStore();
|
||||
store.setArtifacts([
|
||||
{ id: '1', type: 'data_table', data: {} },
|
||||
{ id: '2', type: 'machine', data: {} },
|
||||
{ id: '3', type: 'entity_detail', data: {} },
|
||||
{ id: '4', type: 'document_page', data: {} },
|
||||
]);
|
||||
expect(store.displayArtifacts).toHaveLength(3);
|
||||
expect(store.displayArtifacts.map((a: any) => a.id)).toEqual(['1', '3', '4']);
|
||||
});
|
||||
|
||||
it('filters konsole artifacts (machine, action_bar, status)', () => {
|
||||
const store = useChatStore();
|
||||
store.setArtifacts([
|
||||
{ id: '1', type: 'data_table', data: {} },
|
||||
{ id: '2', type: 'machine', data: {} },
|
||||
{ id: '3', type: 'action_bar', data: {} },
|
||||
{ id: '4', type: 'status', data: {} },
|
||||
]);
|
||||
expect(store.konsoleArtifacts).toHaveLength(3);
|
||||
expect(store.konsoleArtifacts.map((a: any) => a.id)).toEqual(['2', '3', '4']);
|
||||
});
|
||||
|
||||
it('clearArtifacts empties all', () => {
|
||||
const store = useChatStore();
|
||||
store.setArtifacts([{ id: '1', type: 'data_table', data: {} }]);
|
||||
expect(store.artifacts).toHaveLength(1);
|
||||
store.clearArtifacts();
|
||||
expect(store.artifacts).toEqual([]);
|
||||
expect(store.displayArtifacts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state machines', () => {
|
||||
it('defaults to NO_SESSION channel and CONNECTING connection', () => {
|
||||
const store = useChatStore();
|
||||
expect(store.channelState).toBe('NO_SESSION');
|
||||
expect(store.connectionState).toBe('CONNECTING');
|
||||
});
|
||||
|
||||
it('smState shows connection state when not SYNCED', () => {
|
||||
const store = useChatStore();
|
||||
store.connectionState = 'LOADING_HISTORY';
|
||||
expect(store.smState).toBe('LOADING_HISTORY');
|
||||
});
|
||||
|
||||
it('smState shows channel state when SYNCED', () => {
|
||||
const store = useChatStore();
|
||||
store.connectionState = 'SYNCED';
|
||||
store.channelState = 'READY';
|
||||
expect(store.smState).toBe('READY');
|
||||
});
|
||||
|
||||
it('isRunning when AGENT_RUNNING', () => {
|
||||
const store = useChatStore();
|
||||
store.channelState = 'AGENT_RUNNING';
|
||||
expect(store.isRunning).toBe(true);
|
||||
});
|
||||
|
||||
it('not running when READY', () => {
|
||||
const store = useChatStore();
|
||||
store.channelState = 'READY';
|
||||
expect(store.isRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('messages', () => {
|
||||
it('starts with empty messages', () => {
|
||||
const store = useChatStore();
|
||||
expect(store.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('handoverPending is true when confirmNew message exists', () => {
|
||||
const store = useChatStore();
|
||||
store.messages.push({ confirmNew: true, confirmed: false });
|
||||
expect(store.handoverPending).toBe(true);
|
||||
});
|
||||
|
||||
it('handoverPending is false when confirmed', () => {
|
||||
const store = useChatStore();
|
||||
store.messages.push({ confirmNew: true, confirmed: true });
|
||||
expect(store.handoverPending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionCost', () => {
|
||||
it('returns 0 when no pricing or usage', () => {
|
||||
const store = useChatStore();
|
||||
expect(store.sessionCost).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates cost from tokens and pricing', () => {
|
||||
const store = useChatStore();
|
||||
store.finance = {
|
||||
nextTurnFloor: 0,
|
||||
projectionDelta: 0,
|
||||
currentContextTokens: 0,
|
||||
lastTurnCost: 0,
|
||||
pricing: { prompt: 3.0, completion: 15.0 },
|
||||
};
|
||||
store.sessionTotalTokens = {
|
||||
input_tokens: 1000,
|
||||
cache_read_tokens: 0,
|
||||
output_tokens: 500,
|
||||
};
|
||||
// (1000 * 3.0 + 500 * 15.0) / 1_000_000 = 10500 / 1_000_000 = 0.0105
|
||||
expect(store.sessionCost).toBeCloseTo(0.0105);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -12,6 +12,11 @@ export interface TenantFeatures {
|
||||
devTools: boolean;
|
||||
}
|
||||
|
||||
export interface TenantThemes {
|
||||
dark: string;
|
||||
bright: string;
|
||||
}
|
||||
|
||||
export interface TenantConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -19,6 +24,7 @@ export interface TenantConfig {
|
||||
defaultTheme: string;
|
||||
oidcClientId: string;
|
||||
features: TenantFeatures;
|
||||
themes: TenantThemes;
|
||||
}
|
||||
|
||||
// Vite replaces import.meta.env.VITE_TENANT at build time,
|
||||
|
||||
41
src/utils/apiBase.test.ts
Normal file
41
src/utils/apiBase.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// getApiBase caches the result, so we need fresh imports per test
|
||||
describe('getApiBase', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('returns empty string when VITE_WS_URL is not set', async () => {
|
||||
vi.stubEnv('VITE_WS_URL', '');
|
||||
const { getApiBase } = await import('./apiBase');
|
||||
expect(getApiBase()).toBe('');
|
||||
});
|
||||
|
||||
it('converts wss:// to https://', async () => {
|
||||
vi.stubEnv('VITE_WS_URL', 'wss://assay.loop42.de/ws');
|
||||
const { getApiBase } = await import('./apiBase');
|
||||
expect(getApiBase()).toBe('https://assay.loop42.de');
|
||||
});
|
||||
|
||||
it('converts ws:// to http://', async () => {
|
||||
vi.stubEnv('VITE_WS_URL', 'ws://localhost:8000/ws');
|
||||
const { getApiBase } = await import('./apiBase');
|
||||
expect(getApiBase()).toBe('http://localhost:8000');
|
||||
});
|
||||
|
||||
it('returns empty string for invalid URL', async () => {
|
||||
vi.stubEnv('VITE_WS_URL', 'not a url');
|
||||
const { getApiBase } = await import('./apiBase');
|
||||
expect(getApiBase()).toBe('');
|
||||
});
|
||||
|
||||
it('caches the result after first call', async () => {
|
||||
vi.stubEnv('VITE_WS_URL', 'ws://localhost:8000/ws');
|
||||
const { getApiBase } = await import('./apiBase');
|
||||
const first = getApiBase();
|
||||
const second = getApiBase();
|
||||
expect(first).toBe(second);
|
||||
expect(first).toBe('http://localhost:8000');
|
||||
});
|
||||
});
|
||||
@ -1,15 +1,33 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Shared reactive tick — drives all relativeTime re-renders.
|
||||
* One setInterval for the whole app. Any computed that calls relativeTime()
|
||||
* automatically re-evaluates every 5s because it reads `_tick.value`.
|
||||
*/
|
||||
const _tick = ref(0);
|
||||
let _started = false;
|
||||
function _ensureTick() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
setInterval(() => { _tick.value++; }, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-friendly relative time string.
|
||||
* No dependencies — pure function.
|
||||
* Reactive — re-evaluates every 5s when used in Vue computed/template.
|
||||
*/
|
||||
export function relativeTime(isoOrDate: string | Date): string {
|
||||
_ensureTick();
|
||||
void _tick.value; // reactive dependency
|
||||
const then = typeof isoOrDate === 'string' ? new Date(isoOrDate) : isoOrDate;
|
||||
const now = Date.now();
|
||||
const diffMs = now - then.getTime();
|
||||
if (diffMs < 0) return 'just now';
|
||||
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return 'just now';
|
||||
if (sec < 5) return 'just now';
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
<div class="home-view">
|
||||
<WebGLBackground />
|
||||
<div class="home-card">
|
||||
<div class="home-logo"><component :is="THEME_ICONS[theme]" class="w-12 h-12 text-accent" /></div>
|
||||
<h1>{{ THEME_NAMES[theme] }}</h1>
|
||||
<div class="home-logo"><component :is="brandIcon" class="w-12 h-12 text-accent" /></div>
|
||||
<h1>{{ tenant.name }}</h1>
|
||||
<p class="home-sub">Don't Panic.</p>
|
||||
<RouterLink v-if="!isLoggedIn" to="/login" class="home-btn">Sign in →</RouterLink>
|
||||
<RouterLink v-else to="/nyx" class="home-btn">Sign in →</RouterLink>
|
||||
@ -12,9 +12,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { THEME_ICONS, THEME_NAMES, useTheme } from '../composables/useTheme';
|
||||
import { THEME_ICONS } from '../composables/useTheme';
|
||||
import type { Theme } from '../composables/useTheme';
|
||||
import WebGLBackground from "../components/WebGLBackground.vue";
|
||||
import { auth } from "../store";
|
||||
import tenant from '../tenant';
|
||||
const { isLoggedIn } = auth;
|
||||
const { theme } = useTheme();
|
||||
const brandIcon = THEME_ICONS[tenant.themes.dark as Theme];
|
||||
</script>
|
||||
|
||||
@ -12,6 +12,10 @@ const config: TenantConfig = {
|
||||
viewer: true,
|
||||
devTools: true,
|
||||
},
|
||||
themes: {
|
||||
dark: 'titan',
|
||||
bright: 'titan-bright',
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
32
tenants/dev/theme.css
Normal file
32
tenants/dev/theme.css
Normal file
@ -0,0 +1,32 @@
|
||||
/* ── titan dark: defined in base.css :root ──────────────────── */
|
||||
|
||||
/* ── titan bright ───────────────────────────────────────────── */
|
||||
[data-theme="titan-bright"] {
|
||||
--bg: #f1f3f9;
|
||||
--surface: #e8ecf5;
|
||||
--border: #c8cfe0;
|
||||
--chat-bg: #f5f7fc;
|
||||
--agent: #e8ecf5;
|
||||
--agent-border: #a5b4fc;
|
||||
--muted: #dde2ef;
|
||||
--muted-text: #6b7ba0;
|
||||
|
||||
--text: #1a1f2e;
|
||||
--text-dim: #5a6480;
|
||||
|
||||
--accent: #6366f1;
|
||||
--user-bubble: #dce3f5;
|
||||
--primary: #3b82f6;
|
||||
--secondary: #6366f1;
|
||||
--success: #22c55e;
|
||||
--success-dim: #4ade80;
|
||||
--warn: #fbbf24;
|
||||
--error: #f87171;
|
||||
--focus: #4ade80;
|
||||
|
||||
--bg-dim: #e5e9f5;
|
||||
--disabled-opacity: 0.4;
|
||||
|
||||
--panel-bg: #ffffff;
|
||||
--panel-shadow: 0 2px 12px rgba(0,0,0,0.08), 0 0 1px rgba(99,102,241,0.15);
|
||||
}
|
||||
@ -12,6 +12,10 @@ const config: TenantConfig = {
|
||||
viewer: false,
|
||||
devTools: false,
|
||||
},
|
||||
themes: {
|
||||
dark: 'loop42',
|
||||
bright: 'loop42-bright',
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
63
tenants/loop42/theme.css
Normal file
63
tenants/loop42/theme.css
Normal file
@ -0,0 +1,63 @@
|
||||
/* ── loop42 dark ────────────────────────────────────────────── */
|
||||
[data-theme="loop42"] {
|
||||
--bg: #1A212C;
|
||||
--surface: #222a36;
|
||||
--border: #1D7872;
|
||||
--chat-bg: #1e2630;
|
||||
--agent: #222a36;
|
||||
--agent-border: #1D7872;
|
||||
--muted: #2a3340;
|
||||
--muted-text: #71B095;
|
||||
|
||||
--text: #e8e6e0;
|
||||
--text-dim: #71B095;
|
||||
|
||||
--accent: #1D7872;
|
||||
--user-bubble: #2a3340;
|
||||
--primary: #71B095;
|
||||
--btn-bg: #1D7872;
|
||||
--btn-text: #e8e6e0;
|
||||
--btn-hover: #71B095;
|
||||
|
||||
--input-bg: #222a36;
|
||||
--input-border: #1D7872;
|
||||
--input-text: #e8e6e0;
|
||||
|
||||
--bg-dim: #1e2630;
|
||||
--disabled-opacity: 0.4;
|
||||
|
||||
--panel-bg: #1e2630;
|
||||
--panel-shadow: 0 2px 12px rgba(0,0,0,0.35), 0 0 1px rgba(29,120,114,0.15);
|
||||
}
|
||||
|
||||
/* ── loop42 bright ──────────────────────────────────────────── */
|
||||
[data-theme="loop42-bright"] {
|
||||
--bg: #f2f5f4;
|
||||
--surface: #e8eeec;
|
||||
--border: #b8d4d0;
|
||||
--chat-bg: #f6f9f8;
|
||||
--agent: #e8eeec;
|
||||
--agent-border: #1D7872;
|
||||
--muted: #dce8e6;
|
||||
--muted-text: #5a8a80;
|
||||
|
||||
--text: #1a2422;
|
||||
--text-dim: #4a7870;
|
||||
|
||||
--accent: #1D7872;
|
||||
--user-bubble: #d8ecea;
|
||||
--primary: #1D7872;
|
||||
--btn-bg: #1D7872;
|
||||
--btn-text: #ffffff;
|
||||
--btn-hover: #71B095;
|
||||
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #1D7872;
|
||||
--input-text: #1a2422;
|
||||
|
||||
--bg-dim: #dce8e6;
|
||||
--disabled-opacity: 0.4;
|
||||
|
||||
--panel-bg: #ffffff;
|
||||
--panel-shadow: 0 2px 12px rgba(0,0,0,0.08), 0 0 1px rgba(29,120,114,0.15);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
@ -8,17 +9,22 @@ export default defineConfig({
|
||||
__BUILD__: JSON.stringify(buildId),
|
||||
},
|
||||
plugins: [tailwindcss(), vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
target: 'ws://localhost:30800',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://localhost:30800',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user