diff --git a/android/src/components/ProjectsBrowser.tsx b/android/src/components/ProjectsBrowser.tsx index fa5c2c7..d4a7e0c 100644 --- a/android/src/components/ProjectsBrowser.tsx +++ b/android/src/components/ProjectsBrowser.tsx @@ -33,9 +33,12 @@ interface Props { /** Optional — wenn als Modal genutzt, sonst inline */ visible?: boolean; onClose?: () => void; - /** Wird gerufen wenn sich das aktive Projekt aendert — ChatScreen - * refresht dann seinen Banner-State. */ + /** Wird gerufen wenn Stefan ein anderes Projekt fokussiert (App-lokale + * UI-Entscheidung, wechselt den Chat-Focus). */ onActiveChanged?: (project: Project | null) => void; + /** Queue-Status pro Kontext (key "__main__" = Hauptchat, sonst project_id). + * Wenn geliefert: Status-Dot pro Zeile gerendert. */ + queueStatus?: Record; } function _fmtRel(unixSec: number): string { @@ -48,7 +51,14 @@ function _fmtRel(unixSec: number): string { return new Date(unixSec * 1000).toLocaleDateString('de-DE'); } -export const ProjectsBrowser: React.FC = ({ visible = true, onClose, onActiveChanged }) => { +export const ProjectsBrowser: React.FC = ({ visible = true, onClose, onActiveChanged, 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); @@ -88,13 +98,14 @@ export const ProjectsBrowser: React.FC = ({ visible = true, onClose, onAc }, [visible, load]); const switchTo = useCallback((id: string) => { - brainApi.switchProject(id) - .then(status => { - setActiveId(status.active_id || ''); - onActiveChangedRef.current?.(status.active); - }) - .catch(e => Alert.alert('Fehler', String(e?.message || e))); - }, []); + // 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(); @@ -152,6 +163,7 @@ export const ProjectsBrowser: React.FC = ({ visible = true, onClose, onAc const renderItem = ({ item }: { item: Project }) => { const isActive = item.id === activeId; + const dot = _statusDot(item.id); return ( switchTo(item.id)} @@ -160,15 +172,19 @@ export const ProjectsBrowser: React.FC = ({ visible = true, onClose, onAc > + {queueStatus && ( + + )} {item.name} {item.status === 'ended' && beendet} - {isActive && ✓ AKTIV} + {isActive && ✓ FOCUS} {item.description ? ( {item.description} ) : null} {item.turn_count} Turns · zuletzt {_fmtRel(item.last_activity_at)} + {dot.label ? ` · ${dot.label}` : ''} @@ -191,18 +207,29 @@ export const ProjectsBrowser: React.FC = ({ visible = true, onClose, onAc {/* Hauptchat-Eintrag (immer oben) */} - switchTo('')} - style={[s.row, !activeId && s.rowActive]} - > - - - 💬 Hauptchat - {!activeId && ✓ AKTIV} - - Standard-Verlauf, keine Projekt-Zuordnung - - + {(() => { + 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}` : ''} + + + + ); + })()} {loading ? ( diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index f37c07b..cabdd88 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -286,9 +286,15 @@ const ChatScreen: React.FC = () => { const [searchQuery, setSearchQuery] = useState(''); const [searchVisible, setSearchVisible] = useState(false); const [projectsVisible, setProjectsVisible] = useState(false); - const [activeProject, setActiveProject] = useState(null); - // Lookup-Map id → Projekt — fuer Header-Names im Chat-Verlauf + // Focus-One-View: welchen Chat sieht Stefan gerade? + // Leer = Hauptchat, sonst die project_id. Multi-Threading: + // Wechsel des Focus stoppt NICHT ARIAs Arbeit in anderen Projekten — + // die laufen im Brain weiter, wir sehen sie hier nur nicht. + const [focusedProjectId, setFocusedProjectId] = useState(''); + // Lookup-Map id → Projekt (fuer Drawer + Referenzen) const [projectNameById, setProjectNameById] = useState>({}); + // Queue-Status pro Kontext — polled alle 2s, fuer Status-Dots im Drawer + const [queueStatus, setQueueStatus] = useState>({}); const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]); const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''}); @@ -465,27 +471,53 @@ const ChatScreen: React.FC = () => { }, [dispatchWithAck]); // TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle - // sofort greift, ohne Context- oder Event-System) - // Aktives Projekt initial laden + bei RVS-Reconnect refreshen. - // Wird zusaetzlich nach jedem chat-Response refreshed (siehe handleAriaMessage). + // Projekt-Namen laden (Lookup-Map) + focusedProjectId aus AsyncStorage + // wiederherstellen (Default = Hauptchat wenn nichts gespeichert). Der + // Brain hat mit Multi-Threading keinen global-aktiven Projekt-State mehr; + // Focus ist reine App-lokale UI-Info. useEffect(() => { - const loadProject = () => { - brainApi.getProjectStatus() - .then(s => { - setActiveProject(s.active || null); - // Lookup-Map fuellen damit der Chat-Verlauf Header mit Namen rendern kann + const loadNames = () => { + brainApi.listProjects(true) + .then(list => { const map: Record = {}; - for (const p of (s.projects || [])) map[p.id] = p.name; - if (s.active) map[s.active.id] = s.active.name; + for (const p of list) map[p.id] = p.name; setProjectNameById(prev => ({ ...prev, ...map })); }) .catch(() => {}); }; - loadProject(); - const unsub = rvs.onStateChange(state => { if (state === 'connected') loadProject(); }); + loadNames(); + // Letzten Focus aus Storage restoren + AsyncStorage.getItem('aria_focused_project_id').then(v => { + if (v && typeof v === 'string') setFocusedProjectId(v); + }).catch(() => {}); + const unsub = rvs.onStateChange(state => { if (state === 'connected') loadNames(); }); return () => unsub(); }, []); + // Focus in Storage spiegeln damit der letzte Kontext nach Neustart wieder + // da ist. Kein zwingender UX-Fix (Default = Hauptchat waere auch ok), aber + // fuer den Auto-Fall angenehm. + useEffect(() => { + AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {}); + }, [focusedProjectId]); + + // Queue-Status alle 2s pollen — fuers Status-Dot im Focus-Header und + // fuer die Drawer-Anzeige. Nur wenn RVS verbunden ist (sonst 30s Timeout). + useEffect(() => { + let cancelled = false; + const poll = async () => { + if (rvs.getState() !== 'connected') return; + try { + const s = await brainApi.getProjectQueueStatus(); + if (cancelled) return; + setQueueStatus(s.contexts || {}); + } catch {} + }; + poll(); + const iv = setInterval(poll, 2000); + return () => { cancelled = true; clearInterval(iv); }; + }, []); + useEffect(() => { const loadSettings = async () => { const enabled = await AsyncStorage.getItem('aria_tts_enabled'); @@ -825,12 +857,20 @@ const ChatScreen: React.FC = () => { return; } - // project_changed: ARIA hat in einem Tool-Call ein Projekt erstellt / - // betreten / verlassen / beendet. Banner refreshen. + // project_changed: ARIA hat via Tool ein Projekt erstellt/betreten/exited/beendet. + // App entscheidet ob sie den Focus wechselt basierend auf action + payload. if (message.type === 'project_changed') { - brainApi.getProjectStatus() - .then(s => setActiveProject(s.active || null)) - .catch(() => {}); + const p: any = message.payload || {}; + const action = p.action || ''; + // Neuer Projekt-Name in Lookup-Map merken + if (p.id && p.name) { + setProjectNameById(prev => ({ ...prev, [p.id]: p.name })); + } + if (action === 'entered' || action === 'created') { + if (p.id) setFocusedProjectId(p.id); + } else if (action === 'exited') { + setFocusedProjectId(''); + } return; } @@ -1629,116 +1669,14 @@ const ChatScreen: React.FC = () => { [messages], ); - // Projekt-Bloecke: alle Nachrichten eines Projekts erscheinen als EIN Block - // — auch wenn der User zwischenzeitlich raus + wieder rein gegangen ist. - // Dafuer ordnen wir die Nachrichten so um, dass alle Messages eines Projekts - // contiguous werden, verankert am LETZTEN Aktivitaets-Timestamp des Projekts. - // Hauptchat-Nachrichten bleiben chronologisch dazwischen. - const [collapsedProjects, setCollapsedProjects] = useState>(new Set()); - - const reorderedMessages = useMemo(() => { - // Hauptchat-Nachrichten + Projekt-Gruppen trennen - const hauptchat: ChatMessage[] = []; - const groups = new Map(); - for (const m of chatVisibleMessages) { - const pid = m.projectId || ''; - if (!pid) hauptchat.push(m); - else { - const g = groups.get(pid); - if (g) g.push(m); else groups.set(pid, [m]); - } - } - // Events: jede Hauptchat-Bubble + jede Projekt-Gruppe als 1 Event - type Event = - | { ts: number; kind: 'msg'; m: ChatMessage } - | { ts: number; kind: 'group'; msgs: ChatMessage[] }; - const events: Event[] = []; - for (const m of hauptchat) events.push({ ts: m.timestamp, kind: 'msg', m }); - for (const [, msgs] of groups) { - const sorted = [...msgs].sort((a, b) => a.timestamp - b.timestamp); - const anchorTs = sorted[sorted.length - 1].timestamp; - events.push({ ts: anchorTs, kind: 'group', msgs: sorted }); - } - events.sort((a, b) => a.ts - b.ts); - const out: ChatMessage[] = []; - for (const e of events) { - if (e.kind === 'msg') out.push(e.m); - else out.push(...e.msgs); - } - return out; - }, [chatVisibleMessages]); - - const projectMeta = useMemo(() => { - // Pre-compute: welche Message ist Erst-Element ihrer Projekt-Gruppe? - // Plus: wieviele Messages pro Projekt insgesamt (fuer Header-Count). - const firstOfGroup = new Set(); - const counts = new Map(); - let lastPid: string | null = null; - for (const m of reorderedMessages) { - const pid = m.projectId || ''; - if (pid) counts.set(pid, (counts.get(pid) || 0) + 1); - if (pid && pid !== lastPid) firstOfGroup.add(m.id); - lastPid = pid || null; - } - return { firstOfGroup, counts }; - }, [reorderedMessages]); - - // Render-Filter: bei collapsed Projekten zeigen wir NUR das erste Message- - // Item der Gruppe (das traegt den Header). Restliche Messages werden ausge- - // blendet — Header allein steht dann zwischen Hauptchat-Bubbles. + // Focus-One-View (Multi-Threading, 06/2026): Chat zeigt NUR die Nachrichten + // des gerade fokussierten Kontexts. Hauptchat (focusedProjectId leer) → + // alle ungeтагtgeд Nachrichten. Projekt X aktiv → nur Nachrichten mit + // projectId === X. ARIA arbeitet weiterhin in allen Kontexten parallel; + // wir sehen nur den einen. const messagesForRender = useMemo(() => { - return reorderedMessages.filter(m => { - const pid = m.projectId || ''; - if (!pid) return true; - if (!collapsedProjects.has(pid)) return true; - return projectMeta.firstOfGroup.has(m.id); - }); - }, [reorderedMessages, collapsedProjects, projectMeta]); - - // Auto-Collapse beim Projekt-Wechsel: altes Projekt einklappen, neues aufklappen. - const prevActiveIdRef = useRef(''); - useEffect(() => { - const newActive = activeProject?.id || ''; - const prevActive = prevActiveIdRef.current; - if (newActive === prevActive) return; - setCollapsedProjects(prev => { - const next = new Set(prev); - if (prevActive) next.add(prevActive); - if (newActive) next.delete(newActive); - return next; - }); - prevActiveIdRef.current = newActive; - }, [activeProject]); - - // Default: alle Projekte einklappen außer dem aktiven (Stefan-Vision: - // „beim ersten Öffnen sind alle projekte eingeklappt"). - const collapseInitialized = useRef(false); - useEffect(() => { - if (collapseInitialized.current) return; - if (chatVisibleMessages.length === 0) return; - const allPids = new Set(); - for (const m of chatVisibleMessages) { - if (m.projectId) allPids.add(m.projectId); - } - if (allPids.size === 0) return; - const activePid = activeProject?.id || ''; - setCollapsedProjects(prev => { - const next = new Set(prev); - for (const pid of allPids) { - if (pid !== activePid) next.add(pid); - } - return next; - }); - collapseInitialized.current = true; - }, [chatVisibleMessages, activeProject]); - - const toggleProjectCollapse = useCallback((projectId: string) => { - setCollapsedProjects(prev => { - const next = new Set(prev); - if (next.has(projectId)) next.delete(projectId); else next.add(projectId); - return next; - }); - }, []); + return chatVisibleMessages.filter(m => (m.projectId || '') === focusedProjectId); + }, [chatVisibleMessages, focusedProjectId]); const invertedMessages = useMemo(() => [...messagesForRender].reverse(), [messagesForRender]); @@ -1942,7 +1880,7 @@ const ChatScreen: React.FC = () => { const location = await getCurrentLocation(); const cmid = nextClientMsgId(); - const activePid = activeProject?.id || ''; + const activePid = focusedProjectId; const userMsg: ChatMessage = { id: nextId(), sender: 'user', @@ -2461,44 +2399,6 @@ const ChatScreen: React.FC = () => { ); }; - // Wrapper: setzt einen einklappbaren Projekt-Header VOR die Bubble wenn - // diese das erste Element ihrer Projekt-Gruppe ist. Bei collapsed - // → nur Header, keine Bubble. Hauptchat-Nachrichten gehen ohne Header durch. - const renderMessageWithProjectHeader = ({ item }: { item: ChatMessage }) => { - const pid = item.projectId || ''; - if (!pid) return renderMessage({ item }); - const isFirstOfGroup = projectMeta.firstOfGroup.has(item.id); - if (!isFirstOfGroup) return renderMessage({ item }); - const isCollapsed = collapsedProjects.has(pid); - const projectName = projectNameById[pid] || pid; - const projectCount = projectMeta.counts.get(pid) || 0; - const header = ( - toggleProjectCollapse(pid)} - style={{ - marginVertical: 6, marginHorizontal: 8, paddingHorizontal: 10, paddingVertical: 8, - borderRadius: 6, backgroundColor: 'rgba(52,199,89,0.10)', - borderLeftWidth: 3, borderLeftColor: '#34C759', - flexDirection: 'row', alignItems: 'center', gap: 6, - }} - > - - {isCollapsed ? '▶' : '▼'} 📁 {projectName} - - - {projectCount} {projectCount === 1 ? 'Nachricht' : 'Nachrichten'} - - - ); - if (isCollapsed) return header; - return ( - - {header} - {renderMessage({ item })} - - ); - }; - // Extrahiert kopierbare Items aus dem Bubble-Text (URLs, Mails, Telefon). // Wird vom Long-Press/Copy-Menu genutzt damit Stefan den einzelnen Wert // teilen kann ohne den umliegenden Text mitzunehmen. @@ -2651,41 +2551,63 @@ const ChatScreen: React.FC = () => { })()} {/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */} - - setProjectsVisible(true)} style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}> - - {activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'} - - - {activeProject ? 'wechseln ›' : 'Projekte ›'} - - - {activeProject && ( - { - brainApi.switchProject('').then(s => setActiveProject(s.active || null)).catch(() => {}); + {/* Focus-Indicator + Drawer-Toggle. Multi-Threading: das ist reine + Anzeige „was sehe ich gerade" — ARIA arbeitet gleichzeitig in + allen Kontexten weiter, wir zeigen hier nur einen. */} + {(() => { + const isMain = !focusedProjectId; + const focusedName = isMain ? '' : (projectNameById[focusedProjectId] || focusedProjectId); + const focusedQueue = queueStatus[isMain ? '__main__' : focusedProjectId]; + const dot = focusedQueue?.busy + ? { color: '#FF6E6E', label: 'arbeitet' } + : focusedQueue?.queue_size + ? { color: '#FFD60A', label: `Queue: ${focusedQueue.queue_size}` } + : { color: '#34C759', label: 'idle' }; + // Anzahl anderer Kontexte die gerade aktiv sind (fuer Drawer-Badge) + const otherActive = Object.entries(queueStatus).filter(([k, v]) => { + const kFocus = isMain ? '__main__' : focusedProjectId; + if (k === kFocus) return false; + return v.busy || v.queue_size > 0; + }).length; + return ( + - × Hauptchat - - )} - + setProjectsVisible(true)} + style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }} + hitSlop={{top:6,bottom:6,left:6,right:6}} + > + + {otherActive > 0 && ( + + {otherActive} + + )} + + + + {isMain ? '💬 Hauptchat' : `📁 ${focusedName}`} + + + {dot.label} + + + ); + })()} - {/* Projekt-Modal */} + {/* Projekt-Drawer als Modal */} setProjectsVisible(false)} - onActiveChanged={(p) => setActiveProject(p)} + onActiveChanged={(p) => setFocusedProjectId(p?.id || '')} + queueStatus={queueStatus} /> {/* Suchleiste mit Treffer-Navigation */} @@ -2770,7 +2692,7 @@ const ChatScreen: React.FC = () => { }, 300); }} keyExtractor={item => item.id} - renderItem={renderMessageWithProjectHeader} + renderItem={renderMessage} contentContainerStyle={styles.messageList} showsVerticalScrollIndicator={false} ListEmptyComponent={ diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx index 5f4147d..f4b8326 100644 --- a/android/src/screens/SettingsScreen.tsx +++ b/android/src/screens/SettingsScreen.tsx @@ -735,11 +735,11 @@ const SettingsScreen: React.FC = () => { brainApi.listProjects(true) .then(list => setFileFilterProjects(list.map(p => ({ id: p.id, name: p.name })))) .catch(() => {}); - // Default-Filter: aktives Projekt (falls vorhanden), sonst "alle". - brainApi.getProjectStatus() - .then(s => { - if (s.active_id) setFileFilterProjectId(s.active_id); - }) + // Default-Filter: fokussiertes Projekt aus AsyncStorage (falls Stefan + // grade in einem drin ist), sonst "alle". Multi-Threading: Focus ist + // App-lokal, kein Brain-Query mehr. + AsyncStorage.getItem('aria_focused_project_id') + .then(pid => { if (pid) setFileFilterProjectId(pid); }) .catch(() => {}); }, [fileManagerOpen]); diff --git a/android/src/services/brainApi.ts b/android/src/services/brainApi.ts index 5a080b9..50a669a 100644 --- a/android/src/services/brainApi.ts +++ b/android/src/services/brainApi.ts @@ -169,6 +169,16 @@ export interface ProjectStatus { projects: Project[]; } +/** Queue-Status pro Kontext — was gerade arbeitet, was wartet. + * Key "__main__" = Hauptchat, sonst project_id. */ +export interface QueueContextStatus { + busy: boolean; + queue_size: number; +} +export interface ProjectQueueStatus { + contexts: Record; +} + /** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */ export interface Skill { name: string; @@ -590,6 +600,13 @@ export const brainApi = { body: patch, }); }, + + /** Queue-Status: pro Kontext (project_id oder __main__ fuer Hauptchat) + * ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten. + * Wird fuer Status-Dots im Drawer periodisch gepollt. */ + getProjectQueueStatus(): Promise { + return _send('/projects/queue-status'); + }, }; export default brainApi;