- {t('Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert.')}
-
{}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}>
- {t('Benutzereinstellungen öffnen (Tab "Stimme & Sprache")')}
-
+
+ {/* Tab Bar */}
+
+ setActiveTab('general')}
+ >{t('Allgemein')}
+ setActiveTab('personas')}
+ >{t('Gespraechspartner')}
-
+ {/* Tab: Allgemein */}
+ {activeTab === 'general' && (
+ <>
+ {error &&
{error}
}
+ {success &&
{success}
}
- {profile && (
-
-
{t('Statistik')}
-
-
{profile.totalSessions} {t('Sessions gesamt')}
-
{profile.totalMinutes} {t('Minuten gesamt')}
-
{profile.streakDays} {t('Aktueller Streak')}
-
{profile.longestStreak} {t('Längster Streak')}
+
+
{t('Stimme/Sprache')}
+
+ {t('Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert.')}
+
+
+ {t('Benutzereinstellungen oeffnen (Tab "Stimme & Sprache")')}
+
-
+
+
+
+
+ {saving ? t('speichern') : t('Einstellungen speichern')}
+
+ >
)}
-
- {saving ? t('speichern') : t('Einstellungen speichern')}
-
+ {/* Tab: Gespraechspartner */}
+ {activeTab === 'personas' && (
+
+ {personaError &&
{personaError}
}
+
+
+
{t('Gespraechspartner verwalten')}
+ {
+ setShowCreatePersona(true);
+ setPersonaForm({ label: '', description: '', gender: '' });
+ }}>
+ {t('+ Neuer Gespraechspartner')}
+
+
+
+
+ {t('System-Personas koennen nicht bearbeitet oder geloescht werden. Eigene Personas koennen pro Modul zugeordnet werden.')}
+
+
+
+ row.category !== 'builtin',
+ },
+ {
+ type: 'delete' as const,
+ title: t('Loeschen'),
+ visible: (row: CoachingPersona) => row.category !== 'builtin',
+ },
+ ]}
+ hookData={personaHookData}
+ emptyMessage={t('Keine Gespraechspartner vorhanden')}
+ />
+
+
+ {/* Create / Edit Modal */}
+ {(showCreatePersona || editingPersona) && (
+
+
+
+
{editingPersona ? t('Gespraechspartner bearbeiten') : t('Neuer Gespraechspartner')}
+ { setShowCreatePersona(false); setEditingPersona(null); }}>x
+
+
+
+ {t('Name')}
+ setPersonaForm(f => ({ ...f, label: e.target.value }))}
+ placeholder={t('z.B. Kritischer Investor')} />
+
+
+ {t('Beschreibung / Rollenbeschreibung')}
+
+
+ {t('Geschlecht')}
+ setPersonaForm(f => ({ ...f, gender: e.target.value }))}>
+ {t('Nicht angegeben')}
+ {t('Weiblich')}
+ {t('Maennlich')}
+
+
+
+
+ { setShowCreatePersona(false); setEditingPersona(null); }}>
+ {t('Abbrechen')}
+
+
+ {personaSaving ? t('Speichern...') : editingPersona ? t('Speichern') : t('Erstellen')}
+
+
+
+
+ )}
+
+ )}
);
};
diff --git a/src/pages/views/commcoach/index.ts b/src/pages/views/commcoach/index.ts
index f986d2d..71876b3 100644
--- a/src/pages/views/commcoach/index.ts
+++ b/src/pages/views/commcoach/index.ts
@@ -1,3 +1,6 @@
export { CommcoachDashboardView } from './CommcoachDashboardView';
+export { CommcoachAssistantView } from './CommcoachAssistantView';
+export { CommcoachModulesView } from './CommcoachModulesView';
+export { CommcoachSessionView } from './CommcoachSessionView';
export { CommcoachDossierView } from './CommcoachDossierView';
export { CommcoachSettingsView } from './CommcoachSettingsView';
diff --git a/src/pages/views/teamsbot/Teamsbot.module.css b/src/pages/views/teamsbot/Teamsbot.module.css
index 57c31bd..7d4ae26 100644
--- a/src/pages/views/teamsbot/Teamsbot.module.css
+++ b/src/pages/views/teamsbot/Teamsbot.module.css
@@ -436,7 +436,7 @@
.udbSidebar {
width: 280px;
- min-width: 280px;
+ min-width: 180px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
display: flex;
@@ -444,7 +444,7 @@
background: var(--bg-card, #fff);
overflow: hidden;
position: relative;
- transition: width 0.2s, min-width 0.2s;
+ flex-shrink: 0;
}
.udbSidebarCollapsed {
@@ -452,6 +452,20 @@
min-width: 36px;
}
+.udbResizeHandle {
+ width: 5px;
+ flex-shrink: 0;
+ cursor: col-resize;
+ background: transparent;
+ transition: background 0.15s;
+ z-index: 3;
+}
+
+.udbResizeHandle:hover,
+.udbResizeHandle:active {
+ background: var(--accent-color, #4a90d9);
+}
+
.udbToggle {
position: absolute;
top: 8px;
@@ -1303,3 +1317,340 @@
.spinner {
animation: spin 1s linear infinite;
}
+
+/* ============================================================================
+ Agent Status Bubble + Stats Cards + Module Views (Greenfield IA)
+ ============================================================================ */
+
+.agentStatusBubble {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: rgba(74, 144, 217, 0.08);
+ border-radius: 8px;
+ margin: 0.5rem 1rem;
+ font-size: 0.85rem;
+ animation: agentPulse 2s ease-in-out infinite;
+}
+
+@keyframes agentPulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.6; }
+}
+
+.agentStatusDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--primary-color, #4A90D9);
+ animation: agentPulse 1s ease-in-out infinite;
+}
+
+.statsCards {
+ display: flex;
+ gap: 1rem;
+ padding: 0.5rem 1rem;
+ flex-wrap: wrap;
+}
+
+.statsCard {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0.75rem 1.25rem;
+ background: var(--surface-color, #f5f5f5);
+ border-radius: 8px;
+ min-width: 100px;
+}
+
+.statsValue {
+ font-size: 1.4rem;
+ font-weight: 700;
+ color: var(--text-primary, #fff);
+}
+
+.statsLabel {
+ font-size: 0.75rem;
+ color: var(--text-secondary, #888);
+ margin-top: 0.25rem;
+}
+
+.assistantContainer,
+.modulesContainer {
+ padding: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ height: 100%;
+ overflow-y: auto;
+}
+
+.wizardHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.stepIndicator {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.stepDot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--border-color, #ccc);
+}
+
+.stepActive {
+ background: var(--primary-color, #4A90D9);
+}
+
+.wizardContent {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.wizardStep {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.wizardInput,
+.wizardSelect {
+ padding: 0.75rem;
+ border-radius: 8px;
+ border: 1px solid var(--border-color, #ddd);
+ background: var(--bg-input, #fff);
+ color: var(--text-primary, #333);
+ font-size: 1rem;
+}
+
+.wizardTextarea {
+ padding: 0.75rem;
+ border-radius: 8px;
+ border: 1px solid var(--border-color, #ddd);
+ background: var(--bg-input, #fff);
+ color: var(--text-primary, #333);
+ font-size: 1rem;
+ resize: vertical;
+}
+
+.wizardActions {
+ display: flex;
+ gap: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border-color, #e0e0e0);
+}
+
+.moduleChoice {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.modulesHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.modulesList {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.moduleCard {
+ background: var(--bg-card, #fff);
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px solid var(--border-color, #e0e0e0);
+}
+
+.moduleExpanded {
+ border-color: var(--primary-color, #4A90D9);
+}
+
+.moduleRow {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+}
+
+.moduleRow:hover {
+ background: var(--bg-hover, #f5f5f5);
+}
+
+.moduleType {
+ font-size: 0.75rem;
+ padding: 0.2rem 0.5rem;
+ border-radius: 4px;
+ background: rgba(74, 144, 217, 0.1);
+ color: var(--primary-color, #4A90D9);
+ white-space: nowrap;
+}
+
+.moduleTitle {
+ flex: 1;
+ font-weight: 500;
+}
+
+.moduleStatus {
+ font-size: 0.8rem;
+ color: var(--text-secondary, #666);
+}
+
+.moduleActions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.moduleSessionsList {
+ padding: 0.5rem 1rem 1rem 2rem;
+ border-top: 1px solid var(--border-color, #e0e0e0);
+}
+
+.sessionRow {
+ display: flex;
+ gap: 1rem;
+ padding: 0.4rem 0;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+
+.sessionRow:hover {
+ color: var(--primary-color, #4A90D9);
+}
+
+.sessionStatus {
+ font-size: 0.8rem;
+ color: var(--text-secondary, #666);
+}
+
+.noSessions {
+ color: var(--text-secondary, #666);
+ font-style: italic;
+ font-size: 0.9rem;
+}
+
+.confirmOverlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.confirmDialog,
+.editDialog {
+ background: var(--bg-card, #fff);
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 12px;
+ padding: 1.5rem;
+ max-width: 400px;
+ width: 90%;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
+}
+
+.confirmActions {
+ display: flex;
+ gap: 0.75rem;
+ justify-content: flex-end;
+}
+
+.confirmSummary {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 1rem;
+ background: var(--surface-color, #f5f5f5);
+ border-radius: 8px;
+}
+
+.wizardHint {
+ color: var(--text-secondary, #666);
+ font-size: 0.9rem;
+}
+
+.errorBanner {
+ background: rgba(241, 76, 76, 0.1);
+ color: #f14c4c;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+}
+
+.btnPrimary {
+ padding: 0.6rem 1.2rem;
+ border-radius: 8px;
+ border: none;
+ background: var(--primary-color, #4A90D9);
+ color: #fff;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.btnPrimary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btnSecondary {
+ padding: 0.6rem 1.2rem;
+ border-radius: 8px;
+ border: 1px solid var(--border-color, #ddd);
+ background: transparent;
+ color: var(--text-primary, #333);
+ cursor: pointer;
+}
+
+.btnDanger {
+ padding: 0.6rem 1.2rem;
+ border-radius: 8px;
+ border: none;
+ background: #f14c4c;
+ color: #fff;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.btnSmall {
+ padding: 0.3rem 0.7rem;
+ border-radius: 4px;
+ border: 1px solid var(--border-color, #ddd);
+ background: transparent;
+ color: var(--text-primary, #333);
+ font-size: 0.8rem;
+ cursor: pointer;
+}
+
+.btnSmallDanger {
+ padding: 0.3rem 0.7rem;
+ border-radius: 4px;
+ border: 1px solid #f14c4c;
+ background: transparent;
+ color: #f14c4c;
+ font-size: 0.8rem;
+ cursor: pointer;
+}
+
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ color: var(--text-secondary, #666);
+}
diff --git a/src/pages/views/teamsbot/TeamsbotAssistantView.tsx b/src/pages/views/teamsbot/TeamsbotAssistantView.tsx
new file mode 100644
index 0000000..ab589c0
--- /dev/null
+++ b/src/pages/views/teamsbot/TeamsbotAssistantView.tsx
@@ -0,0 +1,190 @@
+/**
+ * TeamsBot Assistant View
+ *
+ * Wizard: Select/create module → Meeting link → Bot selection → "Start bot"
+ */
+import React, { useState, useEffect, useCallback } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
+import * as teamsbotApi from '../../../api/teamsbotApi';
+import { useLanguage } from '../../../providers/language/LanguageContext';
+import styles from './Teamsbot.module.css';
+
+type WizardStep = 'module' | 'meeting' | 'bot' | 'confirm';
+const STEPS: WizardStep[] = ['module', 'meeting', 'bot', 'confirm'];
+
+export const TeamsbotAssistantView: React.FC = () => {
+ const { t } = useLanguage();
+ const { instance, mandateId } = useCurrentInstance();
+ const instanceId = instance?.id || '';
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const preselectedModuleId = searchParams.get('moduleId');
+
+ const [step, setStep] = useState
(preselectedModuleId ? 'meeting' : 'module');
+ const [modules, setModules] = useState([]);
+ const [selectedModuleId, setSelectedModuleId] = useState(preselectedModuleId);
+ const [newModuleTitle, setNewModuleTitle] = useState('');
+ const [createNewModule, setCreateNewModule] = useState(false);
+ const [meetingLink, setMeetingLink] = useState('');
+ const [botName, setBotName] = useState('AI Assistant');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const stepIdx = STEPS.indexOf(step);
+
+ const _loadModules = useCallback(async () => {
+ if (!instanceId) return;
+ try {
+ const result = await teamsbotApi.listModules(instanceId);
+ setModules(result || []);
+ } catch (err) {
+ console.error('Failed to load modules:', err);
+ }
+ }, [instanceId]);
+
+ useEffect(() => { _loadModules(); }, [_loadModules]);
+
+ const _handleNext = () => {
+ const nextIdx = stepIdx + 1;
+ if (nextIdx < STEPS.length) setStep(STEPS[nextIdx]);
+ };
+
+ const _handleBack = () => {
+ const prevIdx = stepIdx - 1;
+ if (prevIdx >= 0) setStep(STEPS[prevIdx]);
+ };
+
+ const _handleStart = async () => {
+ if (!meetingLink.trim()) {
+ setError(t('Meeting-Link erforderlich'));
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ let moduleId = selectedModuleId;
+ if (createNewModule && newModuleTitle.trim()) {
+ const mod = await teamsbotApi.createModule(instanceId, { title: newModuleTitle.trim() });
+ moduleId = mod.id;
+ }
+
+ const session = await teamsbotApi.startSession(instanceId, {
+ meetingLink: meetingLink.trim(),
+ botName,
+ moduleId: moduleId || undefined,
+ } as any);
+
+ navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${session.sessionId || session.id}`);
+ } catch (err: any) {
+ setError(err?.message || t('Fehler beim Starten'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
{t('Neues Meeting starten')}
+
+ {STEPS.map((s, i) => (
+
+ ))}
+
+
+
+ {error &&
{error}
}
+
+
+ {step === 'module' && (
+
+ )}
+
+ {step === 'meeting' && (
+
+
{t('Meeting-Link')}
+ setMeetingLink(e.target.value)}
+ autoFocus
+ />
+
+ )}
+
+ {step === 'bot' && (
+
+
{t('Bot-Name')}
+ setBotName(e.target.value)}
+ />
+
+ )}
+
+ {step === 'confirm' && (
+
+
{t('Zusammenfassung')}
+
+
{t('Modul')}: {createNewModule ? newModuleTitle : (modules.find(m => m.id === selectedModuleId)?.title || t('Adhoc'))}
+
{t('Meeting')}: {meetingLink}
+
{t('Bot')}: {botName}
+
+
+ )}
+
+
+
+ {stepIdx > 0 && (
+
{t('Zurück')}
+ )}
+
+ {step !== 'confirm' ? (
+
{t('Weiter')}
+ ) : (
+
+ {loading ? t('Starte...') : t('Bot starten')}
+
+ )}
+
+
+ );
+};
diff --git a/src/pages/views/teamsbot/TeamsbotDashboardView.tsx b/src/pages/views/teamsbot/TeamsbotDashboardView.tsx
index 6db3a44..ec28ca0 100644
--- a/src/pages/views/teamsbot/TeamsbotDashboardView.tsx
+++ b/src/pages/views/teamsbot/TeamsbotDashboardView.tsx
@@ -73,14 +73,15 @@ export const TeamsbotDashboardView: React.FC = () => {
}
}, [joinMode, instanceId]);
- // Auto-refresh: poll every 10s when there are active sessions
+ // Adaptive polling: 3s with active sessions, 30s otherwise
const pollRef = useRef | null>(null);
useEffect(() => {
const hasActiveSessions = sessions.some(s => ['pending', 'joining', 'active'].includes(s.status));
- if (hasActiveSessions && instanceId) {
+ const interval = hasActiveSessions ? 3000 : 30000;
+ if (instanceId) {
pollRef.current = setInterval(() => {
teamsbotApi.listSessions(instanceId).then(r => setSessions(r.sessions || [])).catch(() => {});
- }, 10000);
+ }, interval);
}
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, [sessions, instanceId]);
diff --git a/src/pages/views/teamsbot/TeamsbotModulesView.tsx b/src/pages/views/teamsbot/TeamsbotModulesView.tsx
new file mode 100644
index 0000000..d704e20
--- /dev/null
+++ b/src/pages/views/teamsbot/TeamsbotModulesView.tsx
@@ -0,0 +1,189 @@
+/**
+ * TeamsBot Modules View
+ *
+ * CRUD list of MeetingModules with expandable session lists per module.
+ */
+import React, { useState, useEffect, useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
+import * as teamsbotApi from '../../../api/teamsbotApi';
+import { useLanguage } from '../../../providers/language/LanguageContext';
+import styles from './Teamsbot.module.css';
+
+const SERIES_TYPE_LABELS: Record = {
+ weekly: 'Wöchentlich',
+ biweekly: 'Zweiwöchentlich',
+ monthly: 'Monatlich',
+ adhoc: 'Adhoc',
+ project: 'Projekt',
+};
+
+const STATUS_LABELS: Record = {
+ active: 'Aktiv',
+ archived: 'Archiviert',
+ completed: 'Abgeschlossen',
+};
+
+export const TeamsbotModulesView: React.FC = () => {
+ const { t } = useLanguage();
+ const { instance, mandateId } = useCurrentInstance();
+ const instanceId = instance?.id || '';
+ const navigate = useNavigate();
+
+ const [modules, setModules] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [expandedId, setExpandedId] = useState(null);
+ const [moduleSessions, setModuleSessions] = useState>({});
+ const [deleteConfirm, setDeleteConfirm] = useState(null);
+ const [editingModule, setEditingModule] = useState(null);
+
+ const _loadModules = useCallback(async () => {
+ if (!instanceId) return;
+ setLoading(true);
+ try {
+ const result = await teamsbotApi.listModules(instanceId);
+ setModules(result || []);
+ } catch (err) {
+ console.error('Failed to load modules:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [instanceId]);
+
+ useEffect(() => { _loadModules(); }, [_loadModules]);
+
+ const _loadModuleSessions = useCallback(async (moduleId: string) => {
+ if (!instanceId) return;
+ try {
+ const detail = await teamsbotApi.getModuleDetail(instanceId, moduleId);
+ setModuleSessions(prev => ({ ...prev, [moduleId]: detail?.sessions || [] }));
+ } catch (err) {
+ console.error('Failed to load module sessions:', err);
+ }
+ }, [instanceId]);
+
+ const _toggleExpand = (moduleId: string) => {
+ if (expandedId === moduleId) {
+ setExpandedId(null);
+ } else {
+ setExpandedId(moduleId);
+ if (!moduleSessions[moduleId]) _loadModuleSessions(moduleId);
+ }
+ };
+
+ const _handleDelete = async (moduleId: string) => {
+ try {
+ await teamsbotApi.deleteModule(instanceId, moduleId);
+ setDeleteConfirm(null);
+ _loadModules();
+ } catch (err) {
+ console.error('Delete failed:', err);
+ }
+ };
+
+ const _handleUpdate = async (moduleId: string, updates: any) => {
+ try {
+ await teamsbotApi.updateModule(instanceId, moduleId, updates);
+ setEditingModule(null);
+ _loadModules();
+ } catch (err) {
+ console.error('Update failed:', err);
+ }
+ };
+
+ return (
+
+
+
{t('Meeting-Module')}
+ navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
+ >
+ {t('Neues Modul')}
+
+
+
+ {loading &&
{t('Laden...')}
}
+
+
+ {modules.map(mod => (
+
+
_toggleExpand(mod.id)}>
+
{t(SERIES_TYPE_LABELS[mod.seriesType] || mod.seriesType)}
+
{mod.title}
+
{t(STATUS_LABELS[mod.status] || mod.status)}
+
+ {
+ e.stopPropagation();
+ navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant?moduleId=${mod.id}`);
+ }}>{t('Meeting starten')}
+ { e.stopPropagation(); setEditingModule(mod); }}>{t('Bearbeiten')}
+ { e.stopPropagation(); setDeleteConfirm(mod.id); }}>{t('Löschen')}
+
+
+
+ {expandedId === mod.id && (
+
+ {(moduleSessions[mod.id] || []).length === 0 ? (
+
{t('Keine Sitzungen')}
+ ) : (
+ (moduleSessions[mod.id] || []).map((sess: any) => (
+
navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/sessions?sessionId=${sess.id}`)}
+ >
+ {sess.botName || 'Bot'}
+ {sess.status}
+ {sess.startedAt ? new Date(sess.startedAt * 1000).toLocaleDateString() : '-'}
+
+ ))
+ )}
+
+ )}
+
+ ))}
+
+
+ {deleteConfirm && (
+
+
+
{t('Modul wirklich löschen? Sessions werden dem Modul entkoppelt.')}
+
+ setDeleteConfirm(null)}>{t('Abbrechen')}
+ _handleDelete(deleteConfirm)}>{t('Löschen')}
+
+
+
+ )}
+
+ {editingModule && (
+
+
+
{t('Modul bearbeiten')}
+
setEditingModule({ ...editingModule, title: e.target.value })}
+ />
+
+
+ )}
+
+ );
+};
diff --git a/src/pages/views/teamsbot/TeamsbotSessionView.tsx b/src/pages/views/teamsbot/TeamsbotSessionView.tsx
index 8d8b7fd..5fc975c 100644
--- a/src/pages/views/teamsbot/TeamsbotSessionView.tsx
+++ b/src/pages/views/teamsbot/TeamsbotSessionView.tsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
-import { useSearchParams } from 'react-router-dom';
+import { useSearchParams, useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type {
@@ -28,8 +28,9 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
*/
export const TeamsbotSessionView: React.FC = () => {
const { t } = useLanguage();
+ const navigate = useNavigate();
- const { instance } = useCurrentInstance();
+ const { instance, mandateId } = useCurrentInstance();
const instanceId = instance?.id || '';
const [searchParams, setSearchParams] = useSearchParams();
const sessionId = searchParams.get('sessionId') || '';
@@ -56,6 +57,12 @@ export const TeamsbotSessionView: React.FC = () => {
timestamp: string;
}>>([]);
+ const [agentStatus, setAgentStatus] = useState<{ toolName?: string; status?: string; reason?: string } | null>(null);
+ const agentStatusTimerRef = useRef | null>(null);
+ const [sessionStats, setSessionStats] = useState(null);
+ const [reconnectTick, setReconnectTick] = useState(0);
+ const reconnectTimerRef = useRef | null>(null);
+
// Director Prompt panel state
const [directorPrompts, setDirectorPrompts] = useState([]);
const [directorText, setDirectorText] = useState('');
@@ -76,6 +83,8 @@ export const TeamsbotSessionView: React.FC = () => {
// UDB Sidebar state
const [udbCollapsed, setUdbCollapsed] = useState(false);
const [udbTab, setUdbTab] = useState('files');
+ const [udbWidth, setUdbWidth] = useState(280);
+ const udbResizing = useRef(false);
const _udbContext: UdbContext | null = instanceId
? { instanceId, featureInstanceId: instanceId }
: null;
@@ -156,22 +165,21 @@ export const TeamsbotSessionView: React.FC = () => {
};
}, [instanceId, sessionId]);
- // SSE Live Stream - connect once per session, don't re-create on status changes.
- // We deliberately depend ONLY on (instanceId, sessionId), not on session.status,
- // so transient status transitions (pending -> joining -> active) don't tear down
- // and rebuild the EventSource (which used to flicker botConnected and spawn
- // multiple parallel /stream connections to the gateway).
+ // SSE Live Stream with reconnect support.
+ // Depends on (instanceId, sessionId, reconnectTick) -- reconnectTick is bumped
+ // to force reconnect after connection loss without changing sessionId.
const sseSessionRef = useRef(null);
const sessionStatusRef = useRef(session?.status);
sessionStatusRef.current = session?.status;
useEffect(() => {
if (!instanceId || !sessionId) return;
- // Avoid reconnecting if already streaming this session
- if (sseSessionRef.current === sessionId && eventSourceRef.current) return;
+ // Avoid reconnecting if already streaming this session (unless reconnectTick changed)
+ if (sseSessionRef.current === sessionId && eventSourceRef.current && eventSourceRef.current.readyState !== EventSource.CLOSED) return;
// Don't open a stream for sessions that are known to already be terminal.
const initialStatus = sessionStatusRef.current;
if (initialStatus && !['active', 'joining', 'pending'].includes(initialStatus)) return;
+ if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; }
eventSourceRef.current?.close();
sseSessionRef.current = sessionId;
@@ -189,7 +197,10 @@ export const TeamsbotSessionView: React.FC = () => {
switch (evType) {
case 'sessionState':
- if (sseEvent.data) setSession(prev => prev ? { ...prev, ...sseEvent.data } : sseEvent.data);
+ if (sseEvent.data) {
+ setSession(prev => prev ? { ...prev, ...sseEvent.data } : sseEvent.data);
+ if (sseEvent.data.stats) setSessionStats(sseEvent.data.stats);
+ }
break;
case 'transcript': {
@@ -289,7 +300,15 @@ export const TeamsbotSessionView: React.FC = () => {
case 'agentRun': {
const data = sseEvent.data || {};
- _dlog('AGENT', `${data.status || ''} ${data.reason || ''}`.trim());
+ _dlog('AGENT', `${data.status || ''} ${data.toolName || ''} ${data.reason || ''}`.trim());
+ if (data.status === 'started' || data.status === 'running') {
+ setAgentStatus({ toolName: data.toolName, status: data.status, reason: data.reason });
+ if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
+ agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 15000);
+ } else {
+ if (agentStatusTimerRef.current) clearTimeout(agentStatusTimerRef.current);
+ agentStatusTimerRef.current = setTimeout(() => setAgentStatus(null), 2000);
+ }
break;
}
@@ -315,6 +334,17 @@ export const TeamsbotSessionView: React.FC = () => {
eventSource.onerror = () => {
setIsLive(false);
+ if (eventSource.readyState === EventSource.CLOSED) {
+ _dlog('SSE', 'connection closed, scheduling reconnect');
+ eventSourceRef.current = null;
+ sseSessionRef.current = null;
+ const status = sessionStatusRef.current;
+ if (status && ['active', 'joining', 'pending'].includes(status)) {
+ reconnectTimerRef.current = setTimeout(() => {
+ setReconnectTick(v => v + 1);
+ }, 2000);
+ }
+ }
};
return () => {
@@ -323,27 +353,36 @@ export const TeamsbotSessionView: React.FC = () => {
sseSessionRef.current = null;
setIsLive(false);
setBotConnected(false);
+ if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [instanceId, sessionId]);
+ }, [instanceId, sessionId, reconnectTick]);
- // Polling fallback: refresh session data every 5s when SSE is not connected
+ // Polling fallback: refresh session data every 5s when SSE is not connected.
+ // Uses isActive (boolean) instead of session object to prevent interval resets.
const pollRef = useRef | null>(null);
- const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session]);
+ const isActive = useMemo(() => session && ['pending', 'joining', 'active'].includes(session.status), [session?.status]);
+ const isLiveRef = useRef(isLive);
+ isLiveRef.current = isLive;
useEffect(() => {
- if (instanceId && sessionId && (isActive || !session)) {
- pollRef.current = setInterval(async () => {
- if (isLive) return;
- try {
- const result = await teamsbotApi.getSession(instanceId, sessionId);
- setSession(result.session);
- if (result.transcripts) setTranscripts(result.transcripts);
- if (result.botResponses) setBotResponses(result.botResponses);
- } catch {}
- }, 5000);
- }
+ if (!instanceId || !sessionId) return;
+ if (!isActive) return;
+ pollRef.current = setInterval(async () => {
+ if (isLiveRef.current) return;
+ try {
+ const result = await teamsbotApi.getSession(instanceId, sessionId);
+ setSession(result.session);
+ if (result.transcripts) setTranscripts(result.transcripts);
+ if (result.botResponses) setBotResponses(result.botResponses);
+ // If session became active and SSE is dead, trigger reconnect
+ const newStatus = result.session?.status;
+ if (newStatus && ['active', 'joining', 'pending'].includes(newStatus) && !eventSourceRef.current) {
+ setReconnectTick(v => v + 1);
+ }
+ } catch {}
+ }, 5000);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
- }, [isActive, instanceId, sessionId, isLive, session]);
+ }, [isActive, instanceId, sessionId]);
// Auto-scroll transcript
useEffect(() => {
@@ -588,9 +627,19 @@ export const TeamsbotSessionView: React.FC = () => {
if (loading) return {t('Sitzung laden')}
;
if (noSessions) return (
-
-
{t('Keine Sitzungen vorhanden')}
-
{t('Starte eine neue Sitzung im Dashboard.')}
+
+
{t('Keine aktive Sitzung')}
+
+ {t('Starte ein neues Meeting ueber den Assistenten oder die Module-Seite.')}
+
+
+ navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/assistant`)}
+ >{t('Zum Assistenten')}
+ navigate(`/mandates/${mandateId}/teamsbot/${instanceId}/modules`)}
+ >{t('Zu den Modulen')}
+
);
if (!session) return
{t('Sitzung nicht gefunden')}
;
@@ -626,6 +675,38 @@ export const TeamsbotSessionView: React.FC = () => {
)}
+ {/* Agent Status Bubble (F-fix-2) */}
+ {agentStatus && (
+
+
+ {t('Agent denkt nach')}{agentStatus.toolName ? `: ${agentStatus.toolName}` : '...'}
+
+ )}
+
+ {/* Stats Cards (F-fix-3) */}
+ {sessionStats && (
+
+ {sessionStats.talkingMinutes != null && (
+
+ {Math.round(sessionStats.talkingMinutes)}
+ {t('Sprechminuten')}
+
+ )}
+ {sessionStats.botResponseCount != null && (
+
+ {sessionStats.botResponseCount}
+ {t('Bot-Antworten')}
+
+ )}
+ {sessionStats.avgLatencyMs != null && (
+
+ {Math.round(sessionStats.avgLatencyMs)}ms
+ {t('Ø Latenz')}
+
+ )}
+
+ )}
+
{/* Session Header */}
@@ -648,7 +729,10 @@ export const TeamsbotSessionView: React.FC = () => {
{/* UDB Sidebar (Files / Sources) */}
{_udbContext && (
-
+
setUdbCollapsed((v) => !v)}
@@ -657,16 +741,39 @@ export const TeamsbotSessionView: React.FC = () => {
{udbCollapsed ? '\u25B6' : '\u25C0'}
{!udbCollapsed && (
-
+
)}
)}
+ {_udbContext && !udbCollapsed && (
+
{
+ e.preventDefault();
+ udbResizing.current = true;
+ const startX = e.clientX;
+ const startW = udbWidth;
+ const onMove = (ev: MouseEvent) => {
+ if (!udbResizing.current) return;
+ const newW = Math.max(180, Math.min(600, startW + (ev.clientX - startX)));
+ setUdbWidth(newW);
+ };
+ const onUp = () => {
+ udbResizing.current = false;
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ };
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ }}
+ />
+ )}
{/* Main column */}
diff --git a/src/types/mandate.ts b/src/types/mandate.ts
index fa6ec17..1824530 100644
--- a/src/types/mandate.ts
+++ b/src/types/mandate.ts
@@ -247,7 +247,9 @@ export const FEATURE_REGISTRY: Record = {
icon: 'headset_mic',
views: [
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
- { code: 'sessions', label: 'Sitzungen', path: 'sessions' },
+ { code: 'assistant', label: 'Assistent', path: 'assistant' },
+ { code: 'modules', label: 'Module', path: 'modules' },
+ { code: 'sessions', label: 'Live-Session', path: 'sessions' },
{ code: 'settings', label: 'Einstellungen', path: 'settings' },
]
},
@@ -280,8 +282,9 @@ export const FEATURE_REGISTRY: Record = {
icon: 'account_voice',
views: [
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
- { code: 'coaching', label: 'Coaching', path: 'coaching' },
- { code: 'dossier', label: 'Dossier', path: 'dossier' },
+ { code: 'assistant', label: 'Assistent', path: 'assistant' },
+ { code: 'modules', label: 'Module', path: 'modules' },
+ { code: 'session', label: 'Session', path: 'session' },
{ code: 'settings', label: 'Einstellungen', path: 'settings' },
]
},