Files
ARIA-AGENT/android/src/components/ProjectsBrowser.tsx
T
duffyduckandClaude Opus 4.8 e64043dca5 feat: Datei-Symbol am Projekt (auto) + VM-Startparameter im Desktop-Panel
- Brain: /projects/status + /list liefern has_files + file_count pro Projekt
  (Scan /shared/projects/<id>/). Ersetzt das manuelle Code-Flag als primaeren
  Indikator. VM-Liste liefert boot_cmd (lesbarer aria-vm-Startbefehl).
- App: 📄-Symbol (+ Anzahl) an Projekten mit Dateien im ProjectsBrowser.
  DesktopTile zeigt pro VM den Start-Befehl als Wert dahinter.
- Diagnostic: 📄-Symbol (+ Anzahl, Tooltip) an Projekten mit Dateien.

Hinweis (kein Code): der VNC-Stream laeuft komplett durch RVS — der Port ist nur
der interne QEMU-Display-Port, den die Bridge lokal auf dem Host nutzt; die App
oeffnet nie einen Port (firewall-unabhaengig).

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:31:31 +02:00

505 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<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('');
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<Pick<Project, 'name' | 'description' | 'kind'>> = {};
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 (
<TouchableOpacity
onPress={() => switchTo(item.id)}
onLongPress={() => openEdit(item)}
style={[s.row, isActive && s.rowActive, hidden && s.rowHidden]}
>
<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.has_files && (
<Text style={{ fontSize: 12 }} accessibilityLabel="hat Dateien">📄{item.file_count ? ` ${item.file_count}` : ''}</Text>
)}
{hidden && <Text style={s.hiddenBadge}>versteckt</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>
{/* Auge: verstecken (🙈) / wieder sichtbar (👁). Eigener Touch, damit
der Tap NICHT das Projekt wechselt. */}
<TouchableOpacity
onPress={() => toggleHidden(item)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={s.eyeBtn}
>
<Text style={s.eyeIcon}>{hidden ? '👁' : '🙈'}</Text>
</TouchableOpacity>
</TouchableOpacity>
);
};
const hiddenCount = projects.filter(p => p.hidden).length;
const visibleProjects = showHidden ? projects : projects.filter(p => !p.hidden);
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>
);
})()}
{/* Versteckte-Toggle — nur wenn es welche gibt (oder gerade eingeblendet) */}
{(hiddenCount > 0 || showHidden) && (
<TouchableOpacity onPress={() => setShowHidden(v => !v)} style={s.hiddenToggle}>
<Text style={s.hiddenToggleText}>
{showHidden
? `🙈 Versteckte ausblenden${hiddenCount ? ` (${hiddenCount})` : ''}`
: `👁 Versteckte anzeigen${hiddenCount ? ` (${hiddenCount})` : ''}`}
</Text>
</TouchableOpacity>
)}
{loading ? (
<View style={{ padding: 24, alignItems: 'center' }}>
<ActivityIndicator color="#0096FF" />
</View>
) : err ? (
<Text style={s.errorText}> {err}</Text>
) : (
<FlatList
data={visibleProjects}
keyExtractor={p => p.id}
renderItem={renderItem}
ListEmptyComponent={
projects.length > 0 ? (
<Text style={s.emptyText}>
Alle {hiddenCount} Projekte sind versteckt.{'\n'}
Tipp „👁 Versteckte anzeigen".
</Text>
) : (
<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
/>
<TouchableOpacity
onPress={() => setEditKind(k => (k === 'code' ? 'chat' : 'code'))}
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 }}
>
<Text style={{ color: '#E0E0F0', fontSize: 14 }}>💻 Code-Projekt{'\n'}
<Text style={{ color: '#8888AA', fontSize: 11 }}>zeigt Editor + Desktop im Cockpit</Text>
</Text>
<View style={{
width: 46, height: 26, borderRadius: 13, padding: 3,
backgroundColor: editKind === 'code' ? '#0096FF' : '#2A2A3E',
alignItems: editKind === 'code' ? 'flex-end' : 'flex-start',
}}>
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFFFFF' }} />
</View>
</TouchableOpacity>
<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: {
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;