/** * Projekt-Übersicht + Switcher. * * Modal-Komponente die: * - Den aktuellen Projekt-Status zeigt (Hauptchat oder konkretes Projekt) * - Die Projekt-Liste rendert (sortiert nach letzter Aktivität) * - Per Tap zwischen Projekten wechseln lässt * - Neue Projekte anlegen kann * - Bestehende editieren/beenden/archivieren * * Eingesetzt von ChatScreen (über den Projekt-Indicator) und von * SettingsScreen.tsx in der Section 'projects'. */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, FlatList, Modal, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View, } from 'react-native'; import brainApi, { Project } from '../services/brainApi'; import rvs from '../services/rvs'; import projectFocus from '../services/projectFocus'; interface Props { /** Optional — wenn als Modal genutzt, sonst inline */ visible?: boolean; onClose?: () => void; /** Wird gerufen wenn Stefan ein anderes Projekt fokussiert (App-lokale * UI-Entscheidung, wechselt den Chat-Focus). */ onActiveChanged?: (project: Project | null) => void; /** Der aktuell in der App fokussierte Kontext (App-lokale Source-of-Truth). * Leer = Hauptchat. Steuert das ✓-FOCUS-Highlight. WICHTIG: der Drawer darf * den Focus NICHT aus dem Brain-Status ableiten — im Multi-Threading gibt es * kein globales active_project mehr (status.active ist null), das wuerde den * Focus bei jedem Drawer-Oeffnen auf Hauptchat zuruecksetzen. */ currentFocusId?: string; /** Queue-Status pro Kontext (key "__main__" = Hauptchat, sonst project_id). * Wenn geliefert: Status-Dot pro Zeile gerendert. */ queueStatus?: Record; } function _fmtRel(unixSec: number): string { if (!unixSec) return '?'; const diff = (Date.now() / 1000) - unixSec; if (diff < 60) return 'gerade eben'; if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`; if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`; if (diff < 86400 * 14) return `vor ${Math.floor(diff / 86400)} Tagen`; return new Date(unixSec * 1000).toLocaleDateString('de-DE'); } export const ProjectsBrowser: React.FC = ({ visible = true, onClose, onActiveChanged, currentFocusId, queueStatus }) => { const _statusDot = (pid: string) => { const s = queueStatus?.[pid]; if (!s) return { color: '#555570', label: '' }; if (s.busy) return { color: '#FF6E6E', label: 'arbeitet' }; if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` }; return { color: '#34C759', label: 'idle' }; }; const [projects, setProjects] = useState([]); const [activeId, setActiveId] = useState(''); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); const [newOpen, setNewOpen] = useState(false); const [newName, setNewName] = useState(''); const [newDesc, setNewDesc] = useState(''); const [editing, setEditing] = useState(null); const [editName, setEditName] = useState(''); const [editDesc, setEditDesc] = useState(''); const [editKind, setEditKind] = useState<'code' | 'chat'>('chat'); // Versteckte Projekte standardmaessig ausblenden; Toggle blendet sie // temporaer (gedimmt) ein — zum Ansehen/Auswaehlen oder Wieder-Sichtbarmachen. const [showHidden, setShowHidden] = useState(false); // Refs damit useCallback NICHT bei jeder Re-Render des Parents neu erzeugt // wird (parent uebergibt oft inline-arrow-Callbacks, neue Identity jedes // Render → useCallback re-runs → useEffect refeuert → infinite spinner). const onActiveChangedRef = useRef(onActiveChanged); useEffect(() => { onActiveChangedRef.current = onActiveChanged; }, [onActiveChanged]); const load = useCallback(() => { setLoading(true); setErr(null); brainApi.getProjectStatus() .then(status => { // NUR die Projektliste + Queue uebernehmen. NICHT status.active in den // App-Focus pushen — im Multi-Threading ist das Brain-active_project // bedeutungslos (null), das wuerde den Focus bei jedem Drawer-Oeffnen // auf Hauptchat zuruecksetzen und alle Nachrichten dort landen lassen. setProjects(status.projects || []); }) .catch(e => setErr(String(e?.message || e))) .finally(() => setLoading(false)); }, []); useEffect(() => { if (visible) load(); }, [visible, load]); // Highlight („✓ FOCUS") folgt dem App-Focus (Source-of-Truth), nicht dem // Brain. switchTo setzt activeId zusaetzlich sofort fuer Instant-Feedback. useEffect(() => { setActiveId(currentFocusId || ''); }, [currentFocusId]); // Reload bei RVS-Reconnect — sonst zeigt die Liste den Fast-Fail ewig useEffect(() => { if (!visible) return; const unsub = rvs.onStateChange((state) => { if (state === 'connected') load(); }); return () => unsub(); }, [visible, load]); // Live-Sync: ein anderer Client (Diagnostic / andere App) hat ein Projekt // geaendert (verstecken/anlegen/beenden/…) → project_changed ueber RVS → // Liste neu laden, ohne dass Stefan manuell refreshen muss. useEffect(() => { if (!visible) return; const unsub = rvs.onMessage((msg: any) => { if (msg?.type === 'project_changed') load(); }); return () => unsub(); }, [visible, load]); const switchTo = useCallback((id: string) => { // Multi-Threading: Focus-Wechsel ist reine App-lokale UI-Entscheidung. // Brain wird nicht mehr benachrichtigt (kein globaler active_project mehr). // Wir suchen das Projekt lokal aus der Liste, damit die App den Namen kennt. setActiveId(id); const p = id ? (projects.find(x => x.id === id) || null) : null; onActiveChangedRef.current?.(p); if (onClose) onClose(); }, [projects, onClose]); const createProject = useCallback(() => { const name = newName.trim(); if (!name) return; brainApi.createProject({ name, description: newDesc.trim() }) .then(() => { setNewName(''); setNewDesc(''); setNewOpen(false); load(); }) .catch(e => Alert.alert('Anlegen fehlgeschlagen', String(e?.message || e))); }, [newName, newDesc, load]); const openEdit = useCallback((p: Project) => { setEditing(p); setEditName(p.name); setEditDesc(p.description || ''); setEditKind(p.kind === 'code' ? 'code' : 'chat'); }, []); const saveEdit = useCallback(() => { if (!editing) return; const patch: Partial> = {}; if (editName.trim() && editName.trim() !== editing.name) patch.name = editName.trim(); if (editDesc.trim() !== (editing.description || '')) patch.description = editDesc.trim(); const curKind = editing.kind === 'code' ? 'code' : 'chat'; if (editKind !== curKind) patch.kind = editKind; if (Object.keys(patch).length === 0) { setEditing(null); return; } brainApi.updateProject(editing.id, patch) .then(() => { // Kind sofort in den Workspace spiegeln (Editor/Desktop-Panels). if (patch.kind) projectFocus.setKind(editing.id, patch.kind); setEditing(null); load(); }) .catch(e => Alert.alert('Fehler', String(e?.message || e))); }, [editing, editName, editDesc, editKind, load]); const endProject = useCallback((p: Project) => { Alert.alert(`"${p.name}" beenden?`, 'Bleibt sichtbar, kann nicht mehr aktiv sein außer mit explizitem Wiedereintritt.', [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Beenden', onPress: () => { brainApi.endProject(p.id).then(() => load()).catch(e => Alert.alert('Fehler', String(e?.message || e))); }}, ]); }, [load]); // Nach einer Projekt-Mutation die anderen Clients (Diagnostic, weitere // App-Instanzen) live aktualisieren — via RVS project_changed. RVS echot // NICHT an den Sender zurueck, darum laden wir lokal zusaetzlich selbst. const broadcastProjectsChanged = useCallback(() => { try { rvs.send('project_changed' as any, { reason: 'app' }); } catch {} }, []); const toggleHidden = useCallback((p: Project) => { brainApi.setProjectHidden(p.id, !p.hidden) .then(() => { broadcastProjectsChanged(); load(); }) .catch(e => Alert.alert('Fehler', String(e?.message || e))); }, [load, broadcastProjectsChanged]); const archiveProject = useCallback((p: Project) => { Alert.alert(`"${p.name}" archivieren?`, 'Verschwindet aus der Standardliste. Über "archivierte zeigen" erreichbar.', [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Archivieren', style: 'destructive', onPress: () => { brainApi.archiveProject(p.id) .then(() => { setEditing(null); load(); }) .catch(e => Alert.alert('Fehler', String(e?.message || e))); }}, ]); }, [load]); // ── Render ──────────────────────────────────────────────── const renderItem = ({ item }: { item: Project }) => { const isActive = item.id === activeId; const dot = _statusDot(item.id); const hidden = !!item.hidden; return ( switchTo(item.id)} onLongPress={() => openEdit(item)} style={[s.row, isActive && s.rowActive, hidden && s.rowHidden]} > {queueStatus && ( )} {item.name} {item.has_files && ( 📄{item.file_count ? ` ${item.file_count}` : ''} )} {hidden && versteckt} {item.status === 'ended' && beendet} {isActive && ✓ FOCUS} {item.description ? ( {item.description} ) : null} {item.turn_count} Turns · zuletzt {_fmtRel(item.last_activity_at)} {dot.label ? ` · ${dot.label}` : ''} {/* Auge: verstecken (🙈) / wieder sichtbar (👁). Eigener Touch, damit der Tap NICHT das Projekt wechselt. */} toggleHidden(item)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} style={s.eyeBtn} > {hidden ? '👁' : '🙈'} ); }; const hiddenCount = projects.filter(p => p.hidden).length; const visibleProjects = showHidden ? projects : projects.filter(p => !p.hidden); const body = ( {/* Header */} {onClose && ( )} Projekte setNewOpen(true)} style={s.headerBtn}> + Neu {/* Hauptchat-Eintrag (immer oben) */} {(() => { const dot = _statusDot('__main__'); return ( switchTo('')} style={[s.row, !activeId && s.rowActive]} > {queueStatus && ( )} 💬 Hauptchat {!activeId && ✓ FOCUS} Standard-Verlauf, keine Projekt-Zuordnung {dot.label ? ` · ${dot.label}` : ''} ); })()} {/* Versteckte-Toggle — nur wenn es welche gibt (oder gerade eingeblendet) */} {(hiddenCount > 0 || showHidden) && ( setShowHidden(v => !v)} style={s.hiddenToggle}> {showHidden ? `🙈 Versteckte ausblenden${hiddenCount ? ` (${hiddenCount})` : ''}` : `👁 Versteckte anzeigen${hiddenCount ? ` (${hiddenCount})` : ''}`} )} {loading ? ( ) : err ? ( ⚠ {err} ) : ( p.id} renderItem={renderItem} ListEmptyComponent={ projects.length > 0 ? ( Alle {hiddenCount} Projekte sind versteckt.{'\n'} Tipp „👁 Versteckte anzeigen". ) : ( Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'} „Lass uns ein Projekt 'XY' anlegen". ) } /> )} {/* Neu-Anlegen Modal */} setNewOpen(false)}> Neues Projekt setNewOpen(false)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}> Abbrechen Anlegen + aktivieren {/* Edit Modal */} setEditing(null)}> Projekt bearbeiten setEditKind(k => (k === 'code' ? 'chat' : 'code'))} style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 }} > 💻 Code-Projekt{'\n'} zeigt Editor + Desktop im Cockpit setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}> Abbrechen Speichern {editing && editing.status !== 'ended' && ( endProject(editing)} style={s.tertiaryBtn}> ⏹ Projekt beenden )} {editing && ( archiveProject(editing)} style={s.tertiaryBtn}> 🗑 Archivieren )} ); // Wenn als Modal genutzt if (onClose) { return ( {body} ); } return body; }; const s = StyleSheet.create({ header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 14, borderBottomWidth: 1, borderColor: '#1E1E2E', backgroundColor: '#080810', }, headerBtn: { padding: 8, minWidth: 60 }, headerBtnText: { color: '#0096FF', fontSize: 18, fontWeight: '600' }, headerTitle: { flex: 1, textAlign: 'center', color: '#E0E0F0', fontSize: 18, fontWeight: '700' }, row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderColor: '#1E1E2E', }, rowActive: { backgroundColor: 'rgba(52,199,89,0.08)', borderLeftWidth: 3, borderLeftColor: '#34C759', }, rowHidden: { opacity: 0.55 }, eyeBtn: { paddingHorizontal: 8, paddingVertical: 6, marginLeft: 6 }, eyeIcon: { fontSize: 18 }, hiddenBadge: { color: '#B392F0', fontSize: 10, fontWeight: '700', backgroundColor: 'rgba(179,146,240,0.15)', paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 }, hiddenToggle: { paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderColor: '#1E1E2E', backgroundColor: '#0D0D18', }, hiddenToggleText: { color: '#B392F0', fontSize: 12, fontWeight: '600' }, rowName: { color: '#E0E0F0', fontSize: 16, fontWeight: '600' }, rowDesc: { color: '#8888AA', fontSize: 13, marginTop: 4 }, rowMeta: { color: '#555570', fontSize: 11, marginTop: 4 }, activeBadge: { color: '#34C759', fontSize: 10, fontWeight: '800' }, statusBadge: { color: '#FFD60A', fontSize: 10, fontWeight: '700', backgroundColor: 'rgba(255,214,10,0.15)', paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 }, errorText: { color: '#FF6E6E', padding: 16, textAlign: 'center', fontSize: 13 }, emptyText: { color: '#555570', padding: 24, textAlign: 'center', fontSize: 13, lineHeight: 19 }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', paddingHorizontal: 20, }, modalCard: { backgroundColor: '#15151E', borderRadius: 12, padding: 18 }, modalTitle: { color: '#E0E0F0', fontSize: 18, fontWeight: '700', marginBottom: 14 }, input: { backgroundColor: '#0A0A14', borderRadius: 6, color: '#E0E0F0', paddingHorizontal: 12, paddingVertical: 10, fontSize: 14, marginBottom: 8, borderWidth: 1, borderColor: '#2A2A3E', }, modalBtn: { flex: 1, alignItems: 'center', paddingVertical: 11, borderRadius: 6 }, modalBtnText: { color: '#fff', fontSize: 14, fontWeight: '700' }, tertiaryBtn: { alignItems: 'center', paddingVertical: 10, marginTop: 8 }, tertiaryBtnText: { color: '#FFD60A', fontSize: 13, fontWeight: '600' }, }); export default ProjectsBrowser;