([]);
@@ -116,13 +119,13 @@ export const CommcoachDossierView: React.FC = () => {
}, [activeTab, coach.session?.id, voice]);
const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
+ const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
const handleSend = useCallback(async () => {
if (!coach.inputValue.trim() || coach.isStreaming) return;
- voice.cancelPendingSpeech();
await coach.sendMessage(coach.inputValue);
- }, [coach, voice]);
+ }, [coach]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
@@ -335,7 +338,10 @@ export const CommcoachDossierView: React.FC = () => {
Session aktiv
{voice.state === 'botSpeaking' && (
-
+ <>
+
+
+ >
)}
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
diff --git a/src/pages/views/commcoach/useVoiceController.ts b/src/pages/views/commcoach/useVoiceController.ts
index 71d8c4b..142d4d6 100644
--- a/src/pages/views/commcoach/useVoiceController.ts
+++ b/src/pages/views/commcoach/useVoiceController.ts
@@ -4,18 +4,15 @@
* States: idle | listening | botSpeaking | interrupted
* Muted: orthogonal boolean flag (independent of main state)
*
- * Recognition is STOPPED during botSpeaking or when muted=true.
- * Recognition is STARTED when entering listening/interrupted AND muted=false.
- * Each start() creates a fresh results session (processedIndex resets to 0).
+ * Uses the generic useVoiceStream hook for mic capture + STT streaming.
+ * Google Streaming STT handles silence detection natively.
*/
-import { useState, useRef, useCallback, useEffect } from 'react';
+import { useState, useRef, useCallback } from 'react';
+import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
-const SILENCE_TIMEOUT_MS = 1000;
-const REC_AUTORESTART_DELAY_MS = 300;
-
export interface VoiceControllerApi {
state: VoiceState;
muted: boolean;
@@ -26,28 +23,25 @@ export interface VoiceControllerApi {
ttsPaused: () => void;
ttsEnded: () => void;
toggleMute: () => void;
- cancelPendingSpeech: () => void;
}
-export function useVoiceController(onMessage: (text: string) => void): VoiceControllerApi {
+export interface VoiceControllerCallbacks {
+ onFinalText?: (text: string) => void | Promise;
+ onInterimText?: (text: string) => void;
+}
+
+export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
const [state, setState] = useState('idle');
const [muted, setMuted] = useState(false);
- const [liveTranscript, setLiveTranscript] = useState('');
const stateRef = useRef('idle');
const mutedRef = useRef(false);
- const streamRef = useRef(null);
- const recognitionRef = useRef(null);
- const transcriptPartsRef = useRef([]);
- const processedIndexRef = useRef(0);
- const silenceTimerRef = useRef | null>(null);
- const onMessageRef = useRef(onMessage);
- onMessageRef.current = onMessage;
+ const cbRef = useRef(callbacks);
+ cbRef.current = callbacks;
const _dlog = useCallback((tag: string, info?: string) => {
const t = new Date();
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
- const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`;
- (window as any).__dlog?.(entry);
+ (window as any).__dlog?.(`[${ts}] ${tag}${info ? ' ' + info : ''}`);
}, []);
const _setState = useCallback((next: VoiceState) => {
@@ -64,183 +58,51 @@ export function useVoiceController(onMessage: (text: string) => void): VoiceCont
_dlog('MUTED', String(next));
}, [_dlog]);
- const _cancelSilenceTimer = useCallback(() => {
- if (silenceTimerRef.current) {
- clearTimeout(silenceTimerRef.current);
- silenceTimerRef.current = null;
- }
- }, []);
-
- const _finalizeTranscript = useCallback(() => {
- const full = transcriptPartsRef.current.join(' ').trim();
- _dlog('SEND', `"${full.substring(0, 80)}"`);
- if (full) onMessageRef.current(full);
- transcriptPartsRef.current = [];
- setLiveTranscript('');
- }, [_dlog]);
-
- const _resetSilenceTimer = useCallback(() => {
- _cancelSilenceTimer();
- silenceTimerRef.current = setTimeout(() => {
- _finalizeTranscript();
- }, SILENCE_TIMEOUT_MS);
- }, [_cancelSilenceTimer, _finalizeTranscript]);
-
- const _startRecognition = useCallback(() => {
- if (mutedRef.current) return;
- const rec = recognitionRef.current;
- if (!rec) return;
- try {
- rec.start();
- _dlog('REC-START', 'ok');
- } catch {
- _dlog('REC-START', 'failed');
- }
- }, [_dlog]);
-
- const _stopRecognition = useCallback(() => {
- const rec = recognitionRef.current;
- if (!rec) return;
- try {
- rec.stop();
- } catch {
- /* ignore */
- }
- }, []);
-
- const _createRecognition = useCallback(() => {
- const SpeechRecognitionApi = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
- if (!SpeechRecognitionApi) return;
-
- const recognition = new SpeechRecognitionApi();
- recognition.continuous = true;
- recognition.interimResults = true;
- recognition.lang = 'de-DE';
-
- recognition.onspeechstart = () => {
- if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
- _resetSilenceTimer();
- };
-
- recognition.onresult = (event: SpeechRecognitionEvent) => {
- if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
- const interimParts: string[] = [];
- for (let i = processedIndexRef.current; i < event.results.length; i++) {
- const r = event.results[i];
- if (r.isFinal) {
- const text = r[0].transcript.trim();
- if (text) transcriptPartsRef.current.push(text);
- processedIndexRef.current = i + 1;
- } else {
- const text = r[0].transcript.trim();
- if (text) interimParts.push(text);
- }
- }
- const currentInterim = interimParts.join(' ');
- const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim();
- setLiveTranscript(preview);
- if (preview) _resetSilenceTimer();
- };
-
- recognition.onspeechend = () => {
- if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
- _resetSilenceTimer();
- };
-
- recognition.onend = () => {
- _dlog('REC-END', `state=${stateRef.current} muted=${mutedRef.current}`);
- if (recognitionRef.current !== recognition) return;
- const cur = stateRef.current;
- if (cur === 'botSpeaking' || cur === 'idle' || mutedRef.current) return;
- processedIndexRef.current = 0;
- setTimeout(() => {
- if (recognitionRef.current !== recognition) return;
- if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return;
- if (mutedRef.current) return;
- try {
- recognition.start();
- _dlog('REC-AUTOSTART', 'ok');
- } catch {
- _dlog('REC-AUTOSTART', 'failed');
- }
- }, REC_AUTORESTART_DELAY_MS);
- };
-
- recognition.onerror = (event: any) => {
- _dlog('REC-ERR', event.error);
- if (event.error === 'no-speech' || event.error === 'aborted') return;
- console.warn('SpeechRecognition error:', event.error);
- };
-
- recognitionRef.current = recognition;
- _startRecognition();
- }, [_dlog, _resetSilenceTimer, _startRecognition]);
+ const voiceStream = useVoiceStream({
+ onFinal: (text) => {
+ cbRef.current.onFinalText?.(text);
+ },
+ onInterim: (text) => {
+ cbRef.current.onInterimText?.(text);
+ },
+ onError: (err) => _dlog('VOICE-ERR', String(err)),
+ });
const activate = useCallback(async () => {
if (stateRef.current !== 'idle') return;
_setState('listening');
- transcriptPartsRef.current = [];
- processedIndexRef.current = 0;
- setLiveTranscript('');
-
try {
- if (!streamRef.current) {
- const stream = await navigator.mediaDevices.getUserMedia({
- audio: { echoCancellation: true, noiseSuppression: true },
- });
- streamRef.current = stream;
- }
- _createRecognition();
+ await voiceStream.start('de-DE');
} catch (err) {
- console.warn('Mic access failed:', err);
+ _dlog('MIC-ERR', String(err));
_setState('idle');
}
- }, [_setState, _createRecognition]);
+ }, [_setState, voiceStream, _dlog]);
const deactivate = useCallback(() => {
- _cancelSilenceTimer();
+ voiceStream.stop();
_setState('idle');
- if (recognitionRef.current) {
- try { recognitionRef.current.stop(); } catch { /* ignore */ }
- recognitionRef.current = null;
- }
- if (streamRef.current) {
- streamRef.current.getTracks().forEach(t => t.stop());
- streamRef.current = null;
- }
- transcriptPartsRef.current = [];
- processedIndexRef.current = 0;
- setLiveTranscript('');
- }, [_setState, _cancelSilenceTimer]);
+ }, [_setState, voiceStream]);
const ttsPlaying = useCallback(() => {
const cur = stateRef.current;
if (cur === 'idle') return;
- _cancelSilenceTimer();
- _finalizeTranscript();
- _stopRecognition();
+ voiceStream.stop();
_setState('botSpeaking');
- }, [_setState, _cancelSilenceTimer, _finalizeTranscript, _stopRecognition]);
+ }, [_setState, voiceStream]);
const ttsPaused = useCallback(() => {
- const cur = stateRef.current;
- if (cur !== 'botSpeaking') return;
- transcriptPartsRef.current = [];
- processedIndexRef.current = 0;
- setLiveTranscript('');
+ if (stateRef.current !== 'botSpeaking') return;
_setState('interrupted');
- _startRecognition();
- }, [_setState, _startRecognition]);
+ voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
+ }, [_setState, voiceStream, _dlog]);
const ttsEnded = useCallback(() => {
const cur = stateRef.current;
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
- transcriptPartsRef.current = [];
- processedIndexRef.current = 0;
- setLiveTranscript('');
_setState('listening');
- _startRecognition();
- }, [_setState, _startRecognition]);
+ voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
+ }, [_setState, voiceStream, _dlog]);
const toggleMute = useCallback(() => {
const cur = stateRef.current;
@@ -248,45 +110,23 @@ export function useVoiceController(onMessage: (text: string) => void): VoiceCont
if (mutedRef.current) {
_setMuted(false);
if (cur === 'listening' || cur === 'interrupted') {
- _startRecognition();
+ voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
}
} else {
_setMuted(true);
- _stopRecognition();
+ voiceStream.stop();
}
- }, [_setMuted, _startRecognition, _stopRecognition]);
-
- const cancelPendingSpeech = useCallback(() => {
- _cancelSilenceTimer();
- transcriptPartsRef.current = [];
- setLiveTranscript('');
- _dlog('CANCEL-SPEECH', 'pending speech cleared for text input');
- }, [_cancelSilenceTimer, _dlog]);
-
- useEffect(() => {
- return () => {
- if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
- if (recognitionRef.current) {
- try { recognitionRef.current.stop(); } catch { /* ignore */ }
- recognitionRef.current = null;
- }
- if (streamRef.current) {
- streamRef.current.getTracks().forEach(t => t.stop());
- streamRef.current = null;
- }
- };
- }, []);
+ }, [_setMuted, voiceStream, _dlog]);
return {
state,
muted,
- liveTranscript,
+ liveTranscript: voiceStream.interimText,
activate,
deactivate,
ttsPlaying,
ttsPaused,
ttsEnded,
toggleMute,
- cancelPendingSpeech,
};
}
diff --git a/src/pages/views/trustee/TrusteeExpenseImportView.tsx b/src/pages/views/trustee/TrusteeExpenseImportView.tsx
index 5609ec5..b62da77 100644
--- a/src/pages/views/trustee/TrusteeExpenseImportView.tsx
+++ b/src/pages/views/trustee/TrusteeExpenseImportView.tsx
@@ -444,7 +444,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
const connectionRef = getConnectionReference(msftConnection);
const template = buildTrusteeTemplate(connectionRef, selectedFolder);
const prompt = `\n${JSON.stringify(template)}\n`;
- await api.post(`/api/chatplayground/${instanceId}/start`, { prompt }, { params: { workflowMode: 'Automation' } });
+ await api.post(`/api/automations/${instanceId}/start`, { prompt }, { params: { workflowMode: 'Automation' } });
showSuccess('Started', 'Workflow started. Extract → Process → Sync will run once.');
} catch (err: any) {
const msg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';
diff --git a/src/pages/views/trustee/TrusteeScanUploadView.tsx b/src/pages/views/trustee/TrusteeScanUploadView.tsx
index 290ff3e..f9a6ce1 100644
--- a/src/pages/views/trustee/TrusteeScanUploadView.tsx
+++ b/src/pages/views/trustee/TrusteeScanUploadView.tsx
@@ -166,7 +166,7 @@ export const TrusteeScanUploadView: React.FC = () => {
isPollingRef.current = true;
try {
const chatDataRes = await api.get(
- `/api/chatplayground/${instanceId}/${workflowId}/chatData`,
+ `/api/automations/${instanceId}/workflows/${workflowId}/chatData`,
{
params: latestTimestampRef.current
? { afterTimestamp: latestTimestampRef.current }
@@ -307,7 +307,7 @@ export const TrusteeScanUploadView: React.FC = () => {
const template = buildTemplate(fileIds);
const prompt = `\n${JSON.stringify(template)}\n`;
const response = await api.post(
- `/api/chatplayground/${instanceId}/start`,
+ `/api/automations/${instanceId}/start`,
{ prompt },
{ params: { workflowMode: 'Automation' } }
);
diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx
index 1b39e39..7bb68d7 100644
--- a/src/pages/views/workspace/ChatStream.tsx
+++ b/src/pages/views/workspace/ChatStream.tsx
@@ -19,6 +19,7 @@ interface ChatStreamProps {
pendingEdits: FileEditProposal[];
onAcceptEdit: (editId: string) => void;
onRejectEdit: (editId: string) => void;
+ onOpenEditor?: () => void;
}
export const ChatStream: React.FC = ({
@@ -28,6 +29,7 @@ export const ChatStream: React.FC = ({
pendingEdits,
onAcceptEdit,
onRejectEdit,
+ onOpenEditor,
}) => {
const bottomRef = useRef(null);
@@ -138,10 +140,9 @@ export const ChatStream: React.FC = ({
))}
- {/* File edit proposals */}
- {pendingEdits.filter(e => e.status === 'pending').map((edit) => (
+ {/* File edit proposals -- compact notification cards */}
+ {pendingEdits.filter(e => e.status === 'pending').length > 0 && (
= ({
maxWidth: '85%',
}}
>
-
+
✎
- File Edit Proposal: {edit.fileName}
+ {pendingEdits.filter(e => e.status === 'pending').length} Aenderungsvorschlag(e)
-
- {edit.newContent?.slice(0, 800)}
- {(edit.newContent?.length || 0) > 800 && '\n...'}
-
-
+
+ {pendingEdits.filter(e => e.status === 'pending').map(edit => (
+
+
+ {edit.fileName}
+
+ ))}
+
+
+ {onOpenEditor && (
+
+ )}
- ))}
+ )}
{/* Agent progress */}
{isProcessing && agentProgress && (
diff --git a/src/pages/views/workspace/ConversationList.tsx b/src/pages/views/workspace/ConversationList.tsx
index 46abf22..c70f5f1 100644
--- a/src/pages/views/workspace/ConversationList.tsx
+++ b/src/pages/views/workspace/ConversationList.tsx
@@ -303,21 +303,29 @@ export const ConversationList: React.FC
= ({
}}
/>
) : (
- { e.stopPropagation(); _startEditing(conv); }}
- title={conv.name}
- >
- {conv.name}
-
+ <>
+
+ {_formatTime(conv.lastActivity)}
+
+ { e.stopPropagation(); _startEditing(conv); }}
+ title={conv.name}
+ >
+ {conv.name}
+
+ >
)}
{/* Action buttons (visible on hover) */}
@@ -383,29 +391,6 @@ export const ConversationList: React.FC = ({
)}
- {/* Status + last activity */}
-
-
- {conv.status === 'active' && (
- {'\u25CF'} aktiv
- )}
- {conv.status === 'completed' && (
- {'\u25CF'} abgeschlossen
- )}
- {conv.status === 'archived' && (
- {'\u25CF'} archiviert
- )}
- {!['active', 'completed', 'archived'].includes(conv.status) && (
- {conv.status}
- )}
-
-
- {_formatTime(conv.lastActivity)}
-
-
);
})}
diff --git a/src/pages/views/workspace/FileBrowser.tsx b/src/pages/views/workspace/FileBrowser.tsx
index 6b28842..5c9d3d8 100644
--- a/src/pages/views/workspace/FileBrowser.tsx
+++ b/src/pages/views/workspace/FileBrowser.tsx
@@ -1,76 +1,84 @@
/**
- * FileBrowser -- Tree-structured file browser.
+ * FileBrowser -- Folder-tree file browser for workspace.
*
- * Level 1: Feature instance (group header, collapsible)
- * Level 2: Files sorted alphabetically
- *
- * Supports search, drag-and-drop upload, and file selection.
+ * Uses useFileContext() for folders (shared state with Dateien page).
+ * Uses FolderTree with showFiles=true so folders and files render inline.
*/
import React, { useState, useCallback, useRef, useMemo } from 'react';
import api from '../../../api';
-import type { WorkspaceFile, WorkspaceFolder } from './useWorkspace';
+import FolderTree from '../../../components/FolderTree/FolderTree';
+import type { FileNode } from '../../../components/FolderTree/FolderTree';
+import { useFileContext } from '../../../contexts/FileContext';
+import type { WorkspaceFile } from './useWorkspace';
interface FileBrowserProps {
instanceId: string;
files: WorkspaceFile[];
- folders: WorkspaceFolder[];
onRefresh: () => void;
onFileSelect?: (fileId: string) => void;
}
-interface _InstanceGroup {
- instanceId: string;
- label: string;
- files: WorkspaceFile[];
-}
-
export const FileBrowser: React.FC = ({
instanceId,
files,
- folders: _folders,
onRefresh,
onFileSelect,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
- const [collapsed, setCollapsed] = useState>({});
+ const [selectedFolderId, setSelectedFolderId] = useState(null);
const fileInputRef = useRef(null);
- const _filteredFiles = useMemo(() => {
- if (!searchQuery.trim()) return files;
- const q = searchQuery.toLowerCase();
- return files.filter(f =>
- f.fileName.toLowerCase().includes(q)
- || (f.tags || []).some(t => t.toLowerCase().includes(q)),
- );
+ const {
+ folders,
+ refreshFolders,
+ handleCreateFolder,
+ handleRenameFolder,
+ handleDeleteFolder,
+ handleMoveFolder,
+ handleMoveFolders,
+ handleMoveFile,
+ handleMoveFiles: contextMoveFiles,
+ handleFileDelete,
+ expandedFolderIds,
+ toggleFolderExpanded,
+ } = useFileContext();
+
+ const _folderNodes = useMemo(() =>
+ folders.map(f => ({
+ id: f.id,
+ name: f.name,
+ parentId: f.parentId ?? null,
+ })),
+ [folders],
+ );
+
+ const _fileNodes: FileNode[] = useMemo(() => {
+ let result: WorkspaceFile[] = files;
+ if (searchQuery.trim()) {
+ const q = searchQuery.toLowerCase();
+ result = result.filter(f =>
+ f.fileName.toLowerCase().includes(q)
+ || (f.tags || []).some((t: string) => t.toLowerCase().includes(q)),
+ );
+ }
+ return result
+ .sort((a, b) => a.fileName.localeCompare(b.fileName))
+ .map(f => ({
+ id: f.id,
+ fileName: f.fileName,
+ mimeType: f.mimeType,
+ fileSize: f.fileSize,
+ folderId: f.folderId ?? null,
+ }));
}, [files, searchQuery]);
- const _groups = useMemo((): _InstanceGroup[] => {
- const map: Record = {};
- for (const f of _filteredFiles) {
- const key = f.featureInstanceId || '_workspace';
- if (!map[key]) {
- map[key] = {
- instanceId: key,
- label: f.featureInstanceLabel || (key === '_workspace' ? 'Workspace' : key.slice(0, 8)),
- files: [],
- };
- }
- map[key].files.push(f);
- }
- for (const g of Object.values(map)) {
- g.files.sort((a, b) => a.fileName.localeCompare(b.fileName));
- }
- const groups = Object.values(map);
- groups.sort((a, b) => a.label.localeCompare(b.label));
- return groups;
- }, [_filteredFiles]);
-
- const _toggleGroup = (key: string) => {
- setCollapsed(prev => ({ ...prev, [key]: !prev[key] }));
- };
+ const _refreshAll = useCallback(() => {
+ onRefresh();
+ refreshFolders();
+ }, [onRefresh, refreshFolders]);
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!instanceId || uploading) return;
@@ -84,18 +92,20 @@ export const FileBrowser: React.FC = ({
headers: { 'Content-Type': 'multipart/form-data' },
});
}
- onRefresh();
+ _refreshAll();
} catch (err) {
console.error('File upload failed:', err);
} finally {
setUploading(false);
}
- }, [instanceId, uploading, onRefresh]);
+ }, [instanceId, uploading, _refreshAll]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDragOver(true);
+ if (e.dataTransfer.types.includes('Files')) {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragOver(true);
+ }
}, []);
const _handleDragLeave = useCallback((e: React.DragEvent) => {
@@ -120,9 +130,46 @@ export const FileBrowser: React.FC = ({
}
}, [_uploadFiles]);
+ const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
+ await handleMoveFile(fileId, targetFolderId);
+ onRefresh();
+ }, [handleMoveFile, onRefresh]);
+
+ const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
+ await contextMoveFiles(fileIds, targetFolderId);
+ onRefresh();
+ }, [contextMoveFiles, onRefresh]);
+
+ const _onDeleteFolder = useCallback(async (folderId: string) => {
+ await handleDeleteFolder(folderId);
+ if (selectedFolderId === folderId) setSelectedFolderId(null);
+ onRefresh();
+ }, [handleDeleteFolder, selectedFolderId, onRefresh]);
+
+ const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
+ await api.put(`/api/files/${fileId}`, { fileName: newName });
+ onRefresh();
+ }, [onRefresh]);
+
+ const _onDeleteFile = useCallback(async (fileId: string) => {
+ await handleFileDelete(fileId);
+ onRefresh();
+ }, [handleFileDelete, onRefresh]);
+
+ const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
+ await api.post('/api/files/batch-delete', { fileIds });
+ onRefresh();
+ }, [onRefresh]);
+
+ const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
+ await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
+ refreshFolders();
+ onRefresh();
+ }, [refreshFolders, onRefresh]);
+
return (
= ({
)}
{/* Header */}
-
+
Files
-
+
@@ -165,94 +212,39 @@ export const FileBrowser: React.FC
= ({
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
- border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box',
+ border: '1px solid #ddd', boxSizing: 'border-box',
}}
/>
- {/* Tree */}
- {_groups.length === 0 && (
+ {/* Folder tree with inline files */}
+
+
+ {_fileNodes.length === 0 && (
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
)}
-
- {_groups.map(group => {
- const isCollapsed = !!collapsed[group.instanceId];
- return (
-
- {/* Group header */}
-
_toggleGroup(group.instanceId)}
- style={{
- display: 'flex', alignItems: 'center', gap: 6,
- padding: '5px 6px', cursor: 'pointer', borderRadius: 4,
- background: 'var(--bg-secondary, #f5f5f5)',
- marginBottom: 2,
- }}
- onMouseEnter={e => (e.currentTarget.style.background = '#eee')}
- onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)')}
- >
-
- {isCollapsed ? '\u25B6' : '\u25BC'}
-
- {'\uD83D\uDCC1'}
-
- {group.label}
-
- {group.files.length}
-
-
- {/* Files */}
- {!isCollapsed && group.files.map(file => (
-
onFileSelect?.(file.id)}
- style={{
- padding: '4px 8px 4px 28px', fontSize: 12,
- display: 'flex', alignItems: 'center', gap: 6,
- borderRadius: 4,
- cursor: onFileSelect ? 'pointer' : 'default',
- }}
- onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')}
- onMouseLeave={e => (e.currentTarget.style.background = '')}
- >
-
{_fileIcon(file.mimeType)}
-
-
- {file.fileName}
-
- {file.tags && file.tags.length > 0 && (
-
- {file.tags.map(tag => (
-
- {tag}
-
- ))}
-
- )}
-
-
- {(file.fileSize / 1024).toFixed(0)}K
-
-
- ))}
-
- );
- })}
);
};
-
-function _fileIcon(mime: string): string {
- if (!mime) return '\uD83D\uDCC4';
- if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
- if (mime.includes('pdf')) return '\uD83D\uDCD5';
- if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
- if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
- if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
- if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
- if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
- if (mime.startsWith('audio/')) return '\uD83C\uDFB5';
- if (mime.startsWith('video/')) return '\uD83C\uDFA5';
- return '\uD83D\uDCC4';
-}
diff --git a/src/pages/views/workspace/WorkspaceEditorPage.tsx b/src/pages/views/workspace/WorkspaceEditorPage.tsx
new file mode 100644
index 0000000..8cb7b5d
--- /dev/null
+++ b/src/pages/views/workspace/WorkspaceEditorPage.tsx
@@ -0,0 +1,278 @@
+/**
+ * WorkspaceEditorPage -- Diff editor for reviewing AI agent file edit proposals.
+ *
+ * Full-page layout with:
+ * - Header: back-to-dashboard, accept-all / reject-all
+ * - Tab bar: one tab per pending edit
+ * - Center: Monaco DiffEditor (original vs. modified)
+ * - Footer: status bar with counts and file metadata
+ */
+
+import React, { useMemo, useState, useEffect, useRef } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { DiffEditor } from '@monaco-editor/react';
+import type { editor as monacoEditor } from 'monaco-editor';
+import { useInstanceId } from '../../../hooks/useCurrentInstance';
+import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
+import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
+
+function _getMonacoLanguage(fileName: string): string {
+ const ext = fileName.split('.').pop()?.toLowerCase() || '';
+ const langMap: Record
= {
+ js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
+ py: 'python', json: 'json', html: 'html', css: 'css', md: 'markdown',
+ xml: 'xml', yaml: 'yaml', yml: 'yaml', sh: 'shell', sql: 'sql',
+ txt: 'plaintext', csv: 'plaintext', log: 'plaintext',
+ };
+ return langMap[ext] || 'plaintext';
+}
+
+function _formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+export const WorkspaceEditorPage: React.FC = () => {
+ const instanceId = useInstanceId() || '';
+ const navigate = useNavigate();
+ const { mandateId, featureCode, instanceId: routeInstanceId } = useParams<{
+ mandateId: string; featureCode: string; instanceId: string;
+ }>();
+ const editor = useWorkspaceEditor(instanceId);
+
+ const activeEdit = useMemo(
+ () => editor.edits.find(e => e.id === editor.activeEditId) || null,
+ [editor.edits, editor.activeEditId],
+ );
+
+ const pendingEdits = useMemo(
+ () => editor.edits.filter(e => e.status === 'pending'),
+ [editor.edits],
+ );
+
+ const _goBack = () => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/dashboard`);
+
+ if (!instanceId) {
+ return (
+
+ Keine Workspace-Instanz ausgewaehlt.
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ File Edit Review
+
+
+ {editor.pendingCount} pending
+
+
+
+
+
+
+
+
+
+ {/* Tab bar */}
+ {pendingEdits.length > 0 && (
+
+ {pendingEdits.map(edit => (
+ <_EditorTab
+ key={edit.id}
+ edit={edit}
+ isActive={edit.id === editor.activeEditId}
+ onClick={() => editor.setActiveEditId(edit.id)}
+ />
+ ))}
+
+ )}
+
+ {/* Main content */}
+
+ {editor.isLoading ? (
+
+ Lade Aenderungsvorschlaege...
+
+ ) : pendingEdits.length === 0 ? (
+
+ ✓
+ Keine offenen Aenderungsvorschlaege
+
+
+ ) : activeEdit ? (
+ <_SafeDiffEditor
+ key={activeEdit.id}
+ original={activeEdit.oldContent}
+ modified={activeEdit.newContent}
+ language={_getMonacoLanguage(activeEdit.fileName)}
+ />
+ ) : null}
+
+
+ {/* Footer / action bar for active edit */}
+ {activeEdit && activeEdit.status === 'pending' && (
+
+
+ {activeEdit.fileName}
+ Original: {_formatBytes(activeEdit.oldContent.length)}
+ Geaendert: {_formatBytes(activeEdit.newContent.length)}
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Safe DiffEditor wrapper -- prevents "TextModel got disposed" errors
+// by tracking the editor ref and skipping disposal when already torn down.
+// ---------------------------------------------------------------------------
+
+const _SafeDiffEditor: React.FC<{
+ original: string;
+ modified: string;
+ language: string;
+}> = ({ original, modified, language }) => {
+ const editorRef = useRef(null);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ setReady(true);
+ return () => {
+ if (editorRef.current) {
+ try {
+ editorRef.current.dispose();
+ } catch { /* already disposed */ }
+ editorRef.current = null;
+ }
+ };
+ }, []);
+
+ if (!ready) return null;
+
+ return (
+ { editorRef.current = diffEditor; }}
+ options={{
+ readOnly: true,
+ renderSideBySide: true,
+ minimap: { enabled: false },
+ fontSize: 13,
+ lineNumbers: 'on',
+ scrollBeyondLastLine: false,
+ wordWrap: 'on',
+ originalEditable: false,
+ }}
+ />
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Sub-components
+// ---------------------------------------------------------------------------
+
+const _EditorTab: React.FC<{
+ edit: EditorFileEdit;
+ isActive: boolean;
+ onClick: () => void;
+}> = ({ edit, isActive, onClick }) => (
+
+);
+
+// ---------------------------------------------------------------------------
+// Shared styles
+// ---------------------------------------------------------------------------
+
+const _btnStyle: React.CSSProperties = {
+ padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border-color, #ddd)',
+ background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center',
+};
+
+const _actionBtnStyle: React.CSSProperties = {
+ padding: '5px 14px', borderRadius: 4, border: 'none',
+ cursor: 'pointer', fontSize: 12, fontWeight: 600,
+ display: 'flex', alignItems: 'center', gap: 6,
+};
+
+export default WorkspaceEditorPage;
diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx
index b400553..56bb812 100644
--- a/src/pages/views/workspace/WorkspaceInput.tsx
+++ b/src/pages/views/workspace/WorkspaceInput.tsx
@@ -1,10 +1,11 @@
/**
* WorkspaceInput -- Prompt input with @file autocomplete, attachment bar,
- * voice toggle (live transcript via SpeechRecognition), and data source selection.
+ * voice toggle (generic audio capture hook), and data source selection.
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
+import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import type { WorkspaceFile, DataSource } from './useWorkspace';
const _STT_LANGUAGES = [
@@ -22,13 +23,16 @@ const _STT_LANGUAGES = [
{ code: 'zh-CN', label: 'Chinese' },
];
-function _getSpeechRecognitionApi(): (new () => SpeechRecognition) | null {
- return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null;
-}
-
interface PendingFile {
fileId: string;
fileName: string;
+ itemType?: 'file' | 'folder';
+}
+
+interface TreeItemDrop {
+ id: string;
+ type: 'file' | 'folder';
+ name: string;
}
interface WorkspaceInputProps {
@@ -45,6 +49,8 @@ interface WorkspaceInputProps {
selectedProviders?: string[];
onProvidersChange?: (providers: string[]) => void;
isMobile?: boolean;
+ onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
+ onPasteAsFile?: (file: File) => void;
}
export const WorkspaceInput: React.FC = ({
@@ -61,21 +67,22 @@ export const WorkspaceInput: React.FC = ({
selectedProviders = [],
onProvidersChange,
isMobile = false,
+ onTreeItemsDrop,
+ onPasteAsFile,
}) => {
const [prompt, setPrompt] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteFilter, setAutocompleteFilter] = useState('');
+ const [treeDropOver, setTreeDropOver] = useState(false);
const [voiceActive, setVoiceActive] = useState(false);
const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE');
- const [, setLiveTranscript] = useState('');
const [showLangPicker, setShowLangPicker] = useState(false);
const [attachedFileIds, setAttachedFileIds] = useState([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]);
const textareaRef = useRef(null);
- const recognitionRef = useRef(null);
- const transcriptPartsRef = useRef([]);
- const processedIndexRef = useRef(0);
const promptBeforeVoiceRef = useRef('');
+ const finalizedTextRef = useRef('');
+ const currentInterimRef = useRef('');
useEffect(() => {
localStorage.setItem('workspace_stt_lang', voiceLanguage);
@@ -171,98 +178,60 @@ export const WorkspaceInput: React.FC = ({
);
}, []);
- const _stopRecognition = useCallback(() => {
- if (recognitionRef.current) {
- try { recognitionRef.current.stop(); } catch { /* ignore */ }
- recognitionRef.current = null;
- }
- const finalText = transcriptPartsRef.current.join(' ').trim();
- if (finalText) {
- setPrompt(() => {
- const base = promptBeforeVoiceRef.current;
- return base ? `${base} ${finalText}` : finalText;
- });
- }
- setLiveTranscript('');
- transcriptPartsRef.current = [];
- processedIndexRef.current = 0;
- setVoiceActive(false);
+ const _buildPromptFromRefs = useCallback(() => {
+ const parts = [
+ promptBeforeVoiceRef.current,
+ finalizedTextRef.current,
+ currentInterimRef.current,
+ ].filter(Boolean);
+ return parts.join(' ');
}, []);
+ const voiceStream = useVoiceStream({
+ onFinal: (text) => {
+ finalizedTextRef.current = finalizedTextRef.current
+ ? `${finalizedTextRef.current} ${text}`
+ : text;
+ currentInterimRef.current = '';
+ setPrompt(_buildPromptFromRefs());
+ },
+ onInterim: (text) => {
+ currentInterimRef.current = text;
+ setPrompt(_buildPromptFromRefs());
+ },
+ onError: (error) => {
+ console.warn('Workspace voice stream error', error);
+ },
+ });
+
+ const _stopVoiceCapture = useCallback(() => {
+ if (currentInterimRef.current) {
+ finalizedTextRef.current = finalizedTextRef.current
+ ? `${finalizedTextRef.current} ${currentInterimRef.current}`
+ : currentInterimRef.current;
+ currentInterimRef.current = '';
+ }
+ setPrompt(_buildPromptFromRefs());
+ voiceStream.stop();
+ setVoiceActive(false);
+ }, [voiceStream, _buildPromptFromRefs]);
+
const _toggleVoice = useCallback(async () => {
if (voiceActive) {
- _stopRecognition();
- return;
- }
-
- const SpeechRecognitionApi = _getSpeechRecognitionApi();
- if (!SpeechRecognitionApi) {
- console.error('SpeechRecognition not supported in this browser');
- return;
- }
-
- try {
- await navigator.mediaDevices.getUserMedia({ audio: true });
- } catch {
- console.error('Microphone access denied');
+ _stopVoiceCapture();
return;
}
promptBeforeVoiceRef.current = prompt;
- transcriptPartsRef.current = [];
- processedIndexRef.current = 0;
- setLiveTranscript('');
-
- const recognition = new SpeechRecognitionApi();
- recognition.continuous = true;
- recognition.interimResults = true;
- recognition.lang = voiceLanguage;
-
- recognition.onresult = (event: SpeechRecognitionEvent) => {
- const interimParts: string[] = [];
- for (let i = processedIndexRef.current; i < event.results.length; i++) {
- const r = event.results[i];
- if (r.isFinal) {
- const text = r[0].transcript.trim();
- if (text) transcriptPartsRef.current.push(text);
- processedIndexRef.current = i + 1;
- } else {
- const text = r[0].transcript.trim();
- if (text) interimParts.push(text);
- }
- }
- const finalSoFar = transcriptPartsRef.current.join(' ');
- const interim = interimParts.join(' ');
- const combined = [finalSoFar, interim].filter(Boolean).join(' ');
- setLiveTranscript(combined);
-
- const base = promptBeforeVoiceRef.current;
- const display = base ? `${base} ${combined}` : combined;
- setPrompt(display);
- };
-
- recognition.onerror = (event: any) => {
- if (event.error === 'no-speech' || event.error === 'aborted') return;
- console.warn('SpeechRecognition error:', event.error);
- };
-
- recognition.onend = () => {
- if (!recognitionRef.current) return;
- processedIndexRef.current = 0;
- setTimeout(() => {
- if (!recognitionRef.current) return;
- try { recognitionRef.current.start(); } catch { /* ignore */ }
- }, 300);
- };
-
+ finalizedTextRef.current = '';
+ currentInterimRef.current = '';
try {
- recognition.start();
- recognitionRef.current = recognition;
setVoiceActive(true);
- } catch (err) {
- console.error('SpeechRecognition start failed:', err);
+ await voiceStream.start(voiceLanguage);
+ } catch {
+ setVoiceActive(false);
}
- }, [voiceActive, voiceLanguage, prompt, _stopRecognition]);
+ }, [voiceActive, prompt, voiceStream, voiceLanguage, _stopVoiceCapture]);
const filteredFiles = showAutocomplete
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter))
@@ -272,12 +241,52 @@ export const WorkspaceInput: React.FC = ({
const _horizontalPadding = isMobile ? 12 : 24;
const _controlSize = isMobile ? 38 : 40;
+ const _handlePaste = useCallback((e: React.ClipboardEvent) => {
+ if (!onPasteAsFile) return;
+ const text = e.clipboardData.getData('text/plain');
+ if (text && text.length >= 1000) {
+ e.preventDefault();
+ const blob = new Blob([text], { type: 'text/plain' });
+ const file = new File([blob], `pasted-text-${Date.now()}.txt`, { type: 'text/plain' });
+ onPasteAsFile(file);
+ }
+ }, [onPasteAsFile]);
+
+ const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
+ if (e.dataTransfer.types.includes('application/tree-items')) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+ setTreeDropOver(true);
+ }
+ }, []);
+
+ const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
+
+ const _handlePromptDrop = useCallback((e: React.DragEvent) => {
+ const treeItemsJson = e.dataTransfer.getData('application/tree-items');
+ if (treeItemsJson && onTreeItemsDrop) {
+ e.preventDefault();
+ e.stopPropagation();
+ setTreeDropOver(false);
+ const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
+ onTreeItemsDrop(items);
+ }
+ }, [onTreeItemsDrop]);
+
return (
-
+
{/* Pending uploaded files */}
{pendingFiles.length > 0 && (
= ({
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
- background: '#fff3e0', color: '#e65100', fontWeight: 500,
- border: '1px solid #ffe0b2',
+ background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
+ color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
+ fontWeight: 500,
+ border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
}}
>
- 📎 {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName}
+ {pf.itemType === 'folder' ? '📁' : '📎'} {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName}
{onRemovePendingFile && (