frontend_nyla/src/pages/views/commcoach/CommcoachSessionView.tsx
2026-05-08 00:15:26 +02:00

610 lines
28 KiB
TypeScript

/**
* CommCoach Session View (Refactored)
*
* Shows ONLY the active coaching session: Chat, Voice, TTS, Agent activity.
* Three states:
* 1. No module selected -> hint with links to Assistant / Modules
* 2. Module selected, no session -> persona picker + "Start session"
* 3. Session active -> full chat/voice/TTS interface
*
* Reachable via Assistant wizard or Modules page ("Session starten" button).
* KeepAlive-wrapped — voice sessions persist across tab switches.
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { useCommcoach } from '../../../hooks/useCommcoach';
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
import {
getPersonasApi, getModulePersonasApi,
type CoachingPersona,
type SendMessageOptions,
} from '../../../api/commcoachApi';
import api from '../../../api';
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab, AddToChat_FileItem } from '../../../components/UnifiedDataBar';
import { _defaultProviderSelection } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector';
import { getPageIcon } from '../../../config/pageRegistry';
import styles from './CommcoachDossierView.module.css';
import sessionStyles from './Commcoach.module.css';
import { useVoiceController } from './useVoiceController';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface WorkspaceFileInfo { id: string; fileName: string; mimeType: string; fileSize: number; }
interface DataSourceInfo { id: string; connectionId: string; sourceType: string; path: string; label: string; }
interface FeatureDataSourceInfo { id: string; featureInstanceId: string; featureCode: string; tableName: string; label: string; }
function _formatToolPayload(payload: Record<string, unknown>): string {
try {
const s = JSON.stringify(payload, null, 0);
return s.length > 120 ? s.substring(0, 120) + '...' : s;
} catch { return '[unlesbar]'; }
}
export const CommcoachSessionView: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
const location = useLocation();
const instanceId = useInstanceId();
const mandateId = useMandateId();
const coach = useCommcoach(instanceId);
const { request } = useApiRequest();
const [searchParams] = useSearchParams();
const moduleId = searchParams.get('moduleId');
const isSessionRoute = /\/commcoach\/[^/]+\/session/.test(location.pathname);
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
const [modulePersonaIds, setModulePersonaIds] = useState<string[] | null>(null);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
const [showAgentActivity, setShowAgentActivity] = useState(true);
const [udbCollapsed, setUdbCollapsed] = useState(false);
const [udbTab, setUdbTab] = useState<UdbTab>('files');
const [udbWidth, setUdbWidth] = useState(280);
const udbResizing = useRef(false);
const [wsFiles, setWsFiles] = useState<WorkspaceFileInfo[]>([]);
const [wsDataSources, setWsDataSources] = useState<DataSourceInfo[]>([]);
const [wsFeatureDataSources, setWsFeatureDataSources] = useState<FeatureDataSourceInfo[]>([]);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
const [attachedDsIds, setAttachedDsIds] = useState<string[]>([]);
const [attachedFdsIds, setAttachedFdsIds] = useState<string[]>([]);
const [providerSelection, _setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection);
const [_showFilePicker, setShowFilePicker] = useState(false);
const [_showSourcePicker, setShowSourcePicker] = useState(false);
const _udbContext: UdbContext | null = instanceId
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
: null;
const inputRef = useRef<HTMLTextAreaElement>(null);
const sendMessageRef = useRef(coach.sendMessage);
sendMessageRef.current = coach.sendMessage;
const attachedFileIdsRef = useRef(attachedFileIds);
attachedFileIdsRef.current = attachedFileIds;
const attachedDsIdsRef = useRef(attachedDsIds);
attachedDsIdsRef.current = attachedDsIds;
const attachedFdsIdsRef = useRef(attachedFdsIds);
attachedFdsIdsRef.current = attachedFdsIds;
const providerSelRef = useRef(providerSelection);
providerSelRef.current = providerSelection;
const voice = useVoiceController({
onFinalText: (text) => {
const opts: SendMessageOptions = {};
if (attachedFileIdsRef.current.length) opts.fileIds = attachedFileIdsRef.current;
if (attachedDsIdsRef.current.length) opts.dataSourceIds = attachedDsIdsRef.current;
if (attachedFdsIdsRef.current.length) opts.featureDataSourceIds = attachedFdsIdsRef.current;
const allowed = providerSelRef.current.include.length > 0 ? providerSelRef.current.include : undefined;
if (allowed) opts.allowedProviders = allowed;
sendMessageRef.current(text, Object.keys(opts).length ? opts : undefined);
},
});
useEffect(() => {
coach.onTtsEventRef.current = (event: TtsEvent) => {
if (event === 'playing') voice.ttsPlaying();
else if (event === 'ended') voice.ttsEnded();
else if (event === 'paused') voice.ttsPaused();
else if (event === 'error') voice.ttsEnded();
};
return () => { coach.onTtsEventRef.current = null; };
}, [coach.onTtsEventRef, voice.ttsPlaying, voice.ttsEnded, voice.ttsPaused]);
// Auto-select module from URL param
useEffect(() => {
if (moduleId && coach.contexts.length > 0 && coach.selectedContextId !== moduleId) {
const found = coach.contexts.find(c => c.id === moduleId);
if (found) coach.selectContext(moduleId);
} else if (!moduleId && !coach.selectedContextId && coach.contexts.length > 0) {
coach.selectContext(coach.contexts[0].id, { skipSessionResume: true });
}
}, [moduleId, coach.contexts, coach.selectedContextId, coach.selectContext]);
useEffect(() => {
if (!instanceId || !isSessionRoute) return;
getPersonasApi(request, instanceId).then(p => setPersonas(p)).catch(() => {});
}, [instanceId, request, isSessionRoute]);
useEffect(() => {
if (!instanceId || !coach.selectedContextId || !isSessionRoute) { setModulePersonaIds(null); return; }
getModulePersonasApi(request, instanceId, coach.selectedContextId)
.then(ids => setModulePersonaIds(ids.length > 0 ? ids : null))
.catch(() => setModulePersonaIds(null));
}, [instanceId, request, coach.selectedContextId, isSessionRoute]);
const _refreshWorkspaceAssets = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/files`).then(r => setWsFiles(r.data.files || [])).catch(() => {});
api.get(`/api/workspace/${instanceId}/datasources`).then(r => setWsDataSources(r.data.dataSources || [])).catch(() => {});
api.get(`/api/workspace/${instanceId}/feature-datasources`).then(r => setWsFeatureDataSources(r.data.featureDataSources || [])).catch(() => {});
}, [instanceId]);
useEffect(() => { _refreshWorkspaceAssets(); }, [_refreshWorkspaceAssets]);
useEffect(() => {
const h = () => _refreshWorkspaceAssets();
window.addEventListener('fileUploaded', h);
return () => window.removeEventListener('fileUploaded', h);
}, [_refreshWorkspaceAssets]);
useEffect(() => {
if (!coach.session) {
voice.deactivate();
} else if (voice.state === 'idle') {
voice.activate();
}
}, [coach.session?.id, voice]);
useEffect(() => {
coach.onDocumentCreatedRef.current = () => {
window.dispatchEvent(new CustomEvent('fileUploaded', { detail: { source: 'commcoachDocument' } }));
};
return () => { coach.onDocumentCreatedRef.current = null; };
}, [coach, _refreshWorkspaceAssets]);
useEffect(() => {
if (coach.agentToolCalls.length > 0) setShowAgentActivity(true);
}, [coach.agentToolCalls.length]);
const handleStopTts = useCallback(() => { coach.stopTts(); voice.ttsStopped(); }, [coach, voice]);
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
const handleSend = useCallback(async () => {
if (!coach.inputValue.trim() || coach.isStreaming) return;
const opts: SendMessageOptions = {};
if (attachedFileIds.length) opts.fileIds = attachedFileIds;
if (attachedDsIds.length) opts.dataSourceIds = attachedDsIds;
if (attachedFdsIds.length) opts.featureDataSourceIds = attachedFdsIds;
const allowed = providerSelection.include.length > 0 ? providerSelection.include : undefined;
if (allowed) opts.allowedProviders = allowed;
await coach.sendMessage(coach.inputValue, Object.keys(opts).length ? opts : undefined);
setAttachedFileIds([]);
setShowSourcePicker(false);
setShowFilePicker(false);
}, [coach, attachedFileIds, attachedDsIds, attachedFdsIds, providerSelection]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
}, [handleSend]);
const _toggleFile = useCallback((fileId: string) => {
setAttachedFileIds(prev => prev.includes(fileId) ? prev.filter(id => id !== fileId) : [...prev, fileId]);
}, []);
const _toggleDs = useCallback((dsId: string) => {
setAttachedDsIds(prev => prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId]);
}, []);
const _toggleFds = useCallback((fdsId: string) => {
setAttachedFdsIds(prev => prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId]);
}, []);
const attachedFileNamesRef = useRef<Record<string, string>>({});
const _handleUdbFileSelect = useCallback((fileId: string, fileName?: string) => {
if (fileName) attachedFileNamesRef.current[fileId] = fileName;
setAttachedFileIds(prev => prev.includes(fileId) ? prev : [...prev, fileId]);
}, []);
const _handleUdbSendToChat = useCallback((items: AddToChat_FileItem[]) => {
for (const item of items) {
if (item.name) attachedFileNamesRef.current[item.id] = item.name;
}
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
setAttachedFileIds(prev => {
const merged = [...prev];
for (const fId of fileIds) {
if (!merged.includes(fId)) merged.push(fId);
}
return merged;
});
}, []);
const _isTreeDrag = useCallback((e: React.DragEvent) => {
return e.dataTransfer.types.includes('application/tree-items') ||
e.dataTransfer.types.includes('application/file-id') ||
e.dataTransfer.types.includes('application/file-ids');
}, []);
const _handleInputDragOver = useCallback((e: React.DragEvent) => {
if (_isTreeDrag(e)) {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
}
}, [_isTreeDrag]);
const _handleInputDrop = useCallback((e: React.DragEvent) => {
const treeJson = e.dataTransfer.getData('application/tree-items');
if (treeJson) {
e.preventDefault();
e.stopPropagation();
try {
const items: AddToChat_FileItem[] = JSON.parse(treeJson);
for (const item of items) {
if (item.name) attachedFileNamesRef.current[item.id] = item.name;
}
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
setAttachedFileIds(prev => {
const merged = [...prev];
for (const fId of fileIds) {
if (!merged.includes(fId)) merged.push(fId);
}
return merged;
});
} catch {}
return;
}
const fileId = e.dataTransfer.getData('application/file-id');
if (fileId) {
e.preventDefault();
e.stopPropagation();
const name = e.dataTransfer.getData('text/plain');
if (name) attachedFileNamesRef.current[fileId] = name;
setAttachedFileIds(prev => prev.includes(fileId) ? prev : [...prev, fileId]);
}
}, []);
const _toolPayloadForDisplay = (payload: Record<string, unknown>): string => {
const formatted = _formatToolPayload(payload);
return formatted === '[unlesbar]' ? t('[unlesbar]') : formatted;
};
if (coach.loadingContexts) {
return <div className={sessionStyles.loading}>{t('Laden...')}</div>;
}
// ========== STATE 1: No module selected ==========
if (!coach.selectedContextId && coach.contexts.length === 0) {
return (
<div className={sessionStyles.modulesContainer} style={{ alignItems: 'center', justifyContent: 'center' }}>
<h3>{t('Keine aktive Session')}</h3>
<p style={{ color: 'var(--text-secondary, #666)', textAlign: 'center', maxWidth: 400 }}>
{t('Erstelle zuerst ein Modul ueber den Assistenten oder starte eine Session ueber die Module-Seite.')}
</p>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
<button className={sessionStyles.btnPrimary}
onClick={() => navigate(`/mandates/${mandateId}/commcoach/${instanceId}/assistant`)}
>{t('Zum Assistenten')}</button>
<button className={sessionStyles.btnSecondary}
onClick={() => navigate(`/mandates/${mandateId}/commcoach/${instanceId}/modules`)}
>{t('Zu den Modulen')}</button>
</div>
</div>
);
}
// ========== STATE 2: Module selected, no active session ==========
if (coach.selectedContextId && !coach.session) {
return (
<div className={sessionStyles.modulesContainer}>
<div style={{ marginBottom: '1rem' }}>
<h3>{coach.selectedContext?.title || t('Modul')}</h3>
{coach.selectedContext?.description && (
<p style={{ color: 'var(--text-secondary, #666)', marginTop: '0.25rem' }}>{coach.selectedContext.description}</p>
)}
</div>
{personas.length > 0 && (() => {
const availablePersonas = modulePersonaIds
? personas.filter(p => modulePersonaIds.includes(p.id))
: personas;
return availablePersonas.length > 0 ? (
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.5rem' }}>{t('Gespraechspartner waehlen')}</label>
{modulePersonaIds && (
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
{t('Fuer dieses Modul sind bestimmte Gespraechspartner konfiguriert.')}
</p>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{availablePersonas.map(p => (
<button
key={p.id}
className={`${sessionStyles.btnSmall} ${selectedPersonaId === p.id ? sessionStyles.btnSmallActive : ''}`}
onClick={() => setSelectedPersonaId(selectedPersonaId === p.id ? undefined : p.id)}
title={p.description}
>
<span>{p.gender === 'f' ? '\u2640' : p.gender === 'm' ? '\u2642' : '\u25CB'}</span>
{' '}{p.label}
</button>
))}
</div>
</div>
) : null;
})()}
<button
className={sessionStyles.btnPrimary}
onClick={() => coach.startSession(selectedPersonaId)}
disabled={!!coach.actionLoading}
style={{ alignSelf: 'flex-start' }}
>
{coach.actionLoading === 'starting'
? t('Wird gestartet...')
: selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
? `${t('Session starten mit')} ${personas.find(p => p.id === selectedPersonaId)!.label}`
: t('Session starten')}
</button>
{coach.error && <div className={sessionStyles.errorBanner}>{coach.error}</div>}
</div>
);
}
// ========== STATE 3: Active session ==========
return (
<div className={styles.dossierLayout}>
{/* UDB Sidebar */}
{_udbContext && (
<div
className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}
style={udbCollapsed ? undefined : { width: udbWidth, minWidth: 180 }}
>
<button
className={styles.udbToggle}
onClick={() => setUdbCollapsed(v => !v)}
title={udbCollapsed ? t('Seitenleiste einblenden') : t('Seitenleiste ausblenden')}
>
{udbCollapsed ? '\u25B6' : '\u25C0'}
</button>
{!udbCollapsed && (
<UnifiedDataBar
context={_udbContext}
activeTab={udbTab}
onTabChange={setUdbTab}
hideTabs={['chats']}
onFileSelect={_handleUdbFileSelect}
onSendToChat_Files={_handleUdbSendToChat}
/>
)}
</div>
)}
{_udbContext && !udbCollapsed && (
<div
className={styles.udbResizeHandle}
onMouseDown={(e) => {
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 Session Content */}
<div className={styles.dossier}>
{/* Session Header */}
<div className={styles.sessionHeader}>
<span className={styles.sessionLabel}>
{coach.selectedContext?.title ? `${coach.selectedContext.title}` : ''}
{t('Session aktiv')}
</span>
<div className={styles.sessionActions}>
{voice.state === 'botSpeaking' && (
<>
<button className={styles.btnSmall} onClick={handlePauseTts}>Pause</button>
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
</>
)}
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
<button className={styles.btnSmall} onClick={handleResumeTts}>{t('Weitersprechen')}</button>
)}
<button
className={`${styles.btnSmall} ${voice.muted ? styles.mutedActive : ''}`}
onClick={voice.toggleMute}
title={voice.muted ? t('Stummschaltung aufheben') : t('stummschalten')}
>
{voice.muted ? t('Stumm') : t('Ton an')}
</button>
<button className={styles.btnSmall} onClick={coach.completeSession} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'completing' ? t('wird abgeschlossen') : t('abschliessen')}
</button>
<button className={styles.btnSmallDanger} onClick={coach.cancelSession} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'cancelling' ? t('wird abgebrochen') : t('Abbrechen')}
</button>
</div>
</div>
{coach.error && <div className={styles.errorBanner || sessionStyles.errorBanner}>{coach.error}</div>}
{/* Messages */}
<AutoScroll scrollDependency={coach.messages.length + (coach.isStreaming ? 1 : 0) + voice.liveTranscript.length}>
<div className={styles.messages}>
{coach.messages.map(msg => (
<div key={msg.id} className={`${styles.message} ${msg.role === 'user' ? styles.messageUser : styles.messageAssistant}`}>
<div className={styles.messageBubble}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</div>
<div className={styles.messageTime}>
{msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) : ''}
</div>
</div>
))}
{voice.liveTranscript && (
<div className={`${styles.message} ${styles.messageUser}`}>
<div className={`${styles.messageBubble} ${styles.messageLive}`}>{voice.liveTranscript}</div>
</div>
)}
{coach.isStreaming && (
<div className={`${styles.message} ${styles.messageAssistant}`}>
<div className={styles.messageBubble}>
{coach.streamingMessage ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{coach.streamingMessage}</ReactMarkdown>
) : (
<div className={styles.typing}>{coach.streamingStatus || t('Coach denkt nach')}<span className={styles.typingDots}>...</span></div>
)}
</div>
</div>
)}
</div>
</AutoScroll>
{/* Agent Activity Panel */}
{(coach.isStreaming || coach.agentToolCalls.length > 0) && (
<div className={styles.agentActivityPanel}>
<button
className={styles.agentActivityHeader}
onClick={() => setShowAgentActivity(prev => !prev)}
type="button"
>
<span className={styles.agentActivityTitle}>
{t('Agent-Aktivitaet')}
{coach.agentToolCalls.length > 0 ? ` (${coach.agentToolCalls.length})` : ''}
</span>
<span className={styles.agentActivityStatus}>
{coach.streamingStatus || (coach.agentToolCalls.length > 0 ? t('Toolaufrufe vorhanden') : t('Warte auf Agent'))}
</span>
<span className={styles.agentActivityChevron}>{showAgentActivity ? '\u25BE' : '\u25B8'}</span>
</button>
{showAgentActivity && (
<div className={styles.agentActivityBody}>
{coach.agentToolCalls.length === 0 ? (
<div className={styles.agentActivityEmpty}>{t('Noch keine Tool-Aufrufe in dieser Antwort.')}</div>
) : (
coach.agentToolCalls.map((toolCall, idx) => (
<div key={`${toolCall.toolName}-${idx}`} className={styles.agentActivityItem}>
<div className={styles.agentActivityItemHeader}>
<span className={styles.agentActivityToolName}>{toolCall.toolName}</span>
<span className={`${styles.agentActivityBadge} ${
toolCall.success === true ? styles.agentActivityBadgeSuccess
: toolCall.success === false ? styles.agentActivityBadgeError
: styles.agentActivityBadgeRunning
}`}>
{toolCall.success === true ? t('fertig') : toolCall.success === false ? t('fehler') : t('laeuft')}
</span>
</div>
{toolCall.args && (
<div className={styles.agentActivityMeta}>
<strong>{t('Argumente:')}</strong> {_toolPayloadForDisplay(toolCall.args)}
</div>
)}
{toolCall.result && (
<div className={styles.agentActivityMeta}>
<strong>{t('Ergebnis:')}</strong> {toolCall.result}
</div>
)}
</div>
))
)}
</div>
)}
</div>
)}
{/* Input Area */}
<div className={styles.inputArea} onDragOver={_handleInputDragOver} onDrop={_handleInputDrop}>
<div className={styles.voiceStatus}>
<span className={`${styles.voiceIndicator} ${voice.state === 'listening' ? styles.voiceActive : ''}`}>
{voice.muted
? t('Stumm - Mikrofon aus')
: voice.state === 'botSpeaking'
? (coach.streamingStatus || t('Coach spricht...'))
: coach.isStreaming
? (coach.streamingStatus || t('Coach denkt nach...'))
: voice.state === 'interrupted'
? t('Unterbrochen - Mikrofon an')
: voice.state === 'listening'
? (voice.liveTranscript ? t('Spricht...') : t('Mikrofon an - bitte sprechen'))
: t('Mikrofon wird gestartet...')}
</span>
</div>
{/* Attachment Chips */}
{(attachedFileIds.length > 0 || attachedDsIds.length > 0 || attachedFdsIds.length > 0) && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', paddingBottom: 4 }}>
{attachedFileIds.map(fId => {
const file = wsFiles.find(f => f.id === fId);
const displayName = file?.fileName || attachedFileNamesRef.current[fId] || fId;
return (
<span key={fId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#e3f2fd', color: '#1565c0', fontWeight: 500 }}>
{displayName}
<button onClick={() => _toggleFile(fId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#1565c0', padding: 0, lineHeight: 1 }}>x</button>
</span>
);
})}
{attachedDsIds.map(dsId => {
const ds = wsDataSources.find(d => d.id === dsId);
return (
<span key={dsId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#e8f5e9', color: '#2e7d32', fontWeight: 500 }}>
{ds?.label || ds?.path || dsId}
<button onClick={() => _toggleDs(dsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1 }}>x</button>
</span>
);
})}
{attachedFdsIds.map(fdsId => {
const fds = wsFeatureDataSources.find(d => d.id === fdsId);
return (
<span key={fdsId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500 }}>
<span style={{ fontSize: 12 }}>{fds ? getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F' : '\uD83D\uDDC3\uFE0F'}</span>
{fds?.label || fdsId}
<button onClick={() => _toggleFds(fdsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1 }}>x</button>
</span>
);
})}
</div>
)}
<div className={styles.textInputRow}>
<textarea
ref={inputRef}
className={styles.textInput}
placeholder={t('Nachricht eingeben')}
value={coach.inputValue}
onChange={e => coach.setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onDragOver={_handleInputDragOver}
onDrop={_handleInputDrop}
rows={1}
disabled={coach.isStreaming}
/>
<button
className={styles.sendBtn}
onClick={handleSend}
disabled={coach.isStreaming || !coach.inputValue.trim()}
>
{t('Senden')}
</button>
</div>
</div>
</div>
</div>
);
};