Kernbug: ProjectsBrowser.load() pushte bei JEDEM Drawer-Oeffnen status.active
in den App-Focus. Im Multi-Threading hat das Brain keinen globalen
active_project-State mehr → status.active ist null → Focus wurde auf Hauptchat
zurueckgesetzt. Folge: nach jedem Drawer-Oeffnen landeten alle Nachrichten im
Hauptchat, Projekte blieben leer.
- ProjectsBrowser: neues Prop currentFocusId (App-Focus = Source-of-Truth).
load() uebernimmt nur noch die Projektliste, kein onActiveChanged(status.active)
mehr. Highlight (✓ FOCUS) folgt currentFocusId.
- ChatScreen: currentFocusId={focusedProjectId} durchgereicht.
- ChatScreen: direkter „← Hauptchat"-Button im Focus-Header (nur im Projekt
sichtbar) — ein Tap statt Drawer→Hauptchat.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
405 lines
16 KiB
TypeScript
405 lines
16 KiB
TypeScript
/**
|
||
* 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';
|
||
|
||
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<string, { busy: boolean; queue_size: number }>;
|
||
}
|
||
|
||
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<Props> = ({ 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<Project[]>([]);
|
||
const [activeId, setActiveId] = useState<string>('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [newOpen, setNewOpen] = useState(false);
|
||
const [newName, setNewName] = useState('');
|
||
const [newDesc, setNewDesc] = useState('');
|
||
const [editing, setEditing] = useState<Project | null>(null);
|
||
const [editName, setEditName] = useState('');
|
||
const [editDesc, setEditDesc] = useState('');
|
||
|
||
// 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]);
|
||
|
||
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 || '');
|
||
}, []);
|
||
|
||
const saveEdit = useCallback(() => {
|
||
if (!editing) return;
|
||
const patch: Partial<Pick<Project, 'name' | 'description'>> = {};
|
||
if (editName.trim() && editName.trim() !== editing.name) patch.name = editName.trim();
|
||
if (editDesc.trim() !== (editing.description || '')) patch.description = editDesc.trim();
|
||
if (Object.keys(patch).length === 0) { setEditing(null); return; }
|
||
brainApi.updateProject(editing.id, patch)
|
||
.then(() => { setEditing(null); load(); })
|
||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||
}, [editing, editName, editDesc, 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]);
|
||
|
||
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);
|
||
return (
|
||
<TouchableOpacity
|
||
onPress={() => switchTo(item.id)}
|
||
onLongPress={() => openEdit(item)}
|
||
style={[s.row, isActive && s.rowActive]}
|
||
>
|
||
<View style={{ flex: 1 }}>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||
{queueStatus && (
|
||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||
)}
|
||
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
|
||
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
|
||
{isActive && <Text style={s.activeBadge}>✓ FOCUS</Text>}
|
||
</View>
|
||
{item.description ? (
|
||
<Text style={s.rowDesc} numberOfLines={2}>{item.description}</Text>
|
||
) : null}
|
||
<Text style={s.rowMeta}>
|
||
{item.turn_count} Turns · zuletzt {_fmtRel(item.last_activity_at)}
|
||
{dot.label ? ` · ${dot.label}` : ''}
|
||
</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
};
|
||
|
||
const body = (
|
||
<View style={{ flex: 1, backgroundColor: '#0A0A14' }}>
|
||
{/* Header */}
|
||
<View style={s.header}>
|
||
{onClose && (
|
||
<TouchableOpacity onPress={onClose} style={s.headerBtn}>
|
||
<Text style={s.headerBtnText}>‹</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
<Text style={s.headerTitle}>Projekte</Text>
|
||
<TouchableOpacity onPress={() => setNewOpen(true)} style={s.headerBtn}>
|
||
<Text style={[s.headerBtnText, { color: '#34C759' }]}>+ Neu</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* Hauptchat-Eintrag (immer oben) */}
|
||
{(() => {
|
||
const dot = _statusDot('__main__');
|
||
return (
|
||
<TouchableOpacity
|
||
onPress={() => switchTo('')}
|
||
style={[s.row, !activeId && s.rowActive]}
|
||
>
|
||
<View style={{ flex: 1 }}>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||
{queueStatus && (
|
||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
|
||
)}
|
||
<Text style={[s.rowName, !activeId && { color: '#34C759' }]}>💬 Hauptchat</Text>
|
||
{!activeId && <Text style={s.activeBadge}>✓ FOCUS</Text>}
|
||
</View>
|
||
<Text style={s.rowMeta}>
|
||
Standard-Verlauf, keine Projekt-Zuordnung
|
||
{dot.label ? ` · ${dot.label}` : ''}
|
||
</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
})()}
|
||
|
||
{loading ? (
|
||
<View style={{ padding: 24, alignItems: 'center' }}>
|
||
<ActivityIndicator color="#0096FF" />
|
||
</View>
|
||
) : err ? (
|
||
<Text style={s.errorText}>⚠ {err}</Text>
|
||
) : (
|
||
<FlatList
|
||
data={projects}
|
||
keyExtractor={p => p.id}
|
||
renderItem={renderItem}
|
||
ListEmptyComponent={
|
||
<Text style={s.emptyText}>
|
||
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
|
||
„Lass uns ein Projekt 'XY' anlegen".
|
||
</Text>
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{/* Neu-Anlegen Modal */}
|
||
<Modal visible={newOpen} animationType="slide" transparent onRequestClose={() => setNewOpen(false)}>
|
||
<View style={s.modalOverlay}>
|
||
<View style={s.modalCard}>
|
||
<Text style={s.modalTitle}>Neues Projekt</Text>
|
||
<TextInput
|
||
value={newName}
|
||
onChangeText={setNewName}
|
||
placeholder="Name (z.B. 'Frankreich-Urlaub')"
|
||
placeholderTextColor="#555570"
|
||
style={s.input}
|
||
autoFocus
|
||
/>
|
||
<TextInput
|
||
value={newDesc}
|
||
onChangeText={setNewDesc}
|
||
placeholder="Beschreibung — kurz, hilft beim Wiederfinden"
|
||
placeholderTextColor="#555570"
|
||
style={[s.input, { height: 70 }]}
|
||
multiline
|
||
/>
|
||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
|
||
<TouchableOpacity onPress={() => setNewOpen(false)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
|
||
<Text style={s.modalBtnText}>Abbrechen</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity onPress={createProject} style={[s.modalBtn, { backgroundColor: '#34C759' }]}>
|
||
<Text style={s.modalBtnText}>Anlegen + aktivieren</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
|
||
{/* Edit Modal */}
|
||
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={() => setEditing(null)}>
|
||
<View style={s.modalOverlay}>
|
||
<View style={s.modalCard}>
|
||
<Text style={s.modalTitle}>Projekt bearbeiten</Text>
|
||
<TextInput
|
||
value={editName}
|
||
onChangeText={setEditName}
|
||
placeholder="Name"
|
||
placeholderTextColor="#555570"
|
||
style={s.input}
|
||
/>
|
||
<TextInput
|
||
value={editDesc}
|
||
onChangeText={setEditDesc}
|
||
placeholder="Beschreibung"
|
||
placeholderTextColor="#555570"
|
||
style={[s.input, { height: 70 }]}
|
||
multiline
|
||
/>
|
||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
|
||
<TouchableOpacity onPress={() => setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
|
||
<Text style={s.modalBtnText}>Abbrechen</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity onPress={saveEdit} style={[s.modalBtn, { backgroundColor: '#34C759' }]}>
|
||
<Text style={s.modalBtnText}>Speichern</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
{editing && editing.status !== 'ended' && (
|
||
<TouchableOpacity onPress={() => endProject(editing)} style={s.tertiaryBtn}>
|
||
<Text style={s.tertiaryBtnText}>⏹ Projekt beenden</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
{editing && (
|
||
<TouchableOpacity onPress={() => archiveProject(editing)} style={s.tertiaryBtn}>
|
||
<Text style={[s.tertiaryBtnText, { color: '#E55C5C' }]}>🗑 Archivieren</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
</View>
|
||
);
|
||
|
||
// Wenn als Modal genutzt
|
||
if (onClose) {
|
||
return (
|
||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||
{body}
|
||
</Modal>
|
||
);
|
||
}
|
||
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: {
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 12,
|
||
borderBottomWidth: 1,
|
||
borderColor: '#1E1E2E',
|
||
},
|
||
rowActive: {
|
||
backgroundColor: 'rgba(52,199,89,0.08)',
|
||
borderLeftWidth: 3,
|
||
borderLeftColor: '#34C759',
|
||
},
|
||
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;
|