fix+feat(projects): Spinner-Bug, Back-Button, kollabierbare Chat-Bloecke, File-Filter

Drei Stefan-Bugs aus dem ersten Deploy-Test plus die fehlenden Polish-
Features fuer die Projekt-Funktion.

Fixes:
- ProjectsBrowser-Spinner-Hang: useRef-Pattern statt useCallback([onActive
  Changed]) — Parent uebergibt inline-arrow-Callbacks, neue Identitaet
  jedes Render → useCallback recomputes → useEffect refeuert → infinite
  Spinner. Fix: Ref-Bridge fuer Callbacks, useCallback mit empty deps.
- ChatScreen Banner: zusaetzlicher × Hauptchat-Button rechts (sichtbar
  nur wenn Projekt aktiv) — ein Tap und zurueck zum Hauptthread, ohne
  Modal-Umweg.

Features:
- Brain ChatOut.project_id: aktive Projekt-ID NACH dem Turn (kann
  durch project_enter/exit-Tools waehrend Turn gewechselt sein). Bridge
  liest sie aus dem /chat-Response und haengt sie an jeden ARIA-Chat-
  Broadcast als payload.projectId.
- App: ChatMessage.projectId-Feld. User-Bubbles werden mit aktiver
  Projekt-ID getaggt vor dem Senden (auch im RVS-Payload). ARIA-Bubbles
  kriegen die ID vom Bridge.
- App: Chat-Verlauf rendert aufeinanderfolgende Project-Messages als
  einklappbaren Block mit Header (▶/▼ + Projekt-Name + Count). Auto-
  Collapse beim Projekt-Wechsel (altes ein, neues aus), Default beim
  ersten Render: alle inaktiven Projekte eingeklappt.
- File-Manager Project-Tagging:
  - diagnostic/server.js: Manifest /shared/config/file_projects.json
    + /api/files-list returnt projectId pro Datei + neuer Endpoint
    /api/files-set-project.
  - bridge/aria_bridge.py: nach App-Upload Auto-Tag mit aktivem Projekt
    (Brain-Status-Query, best-effort fail-silent).
  - App SettingsScreen: scrollbare Projekt-Pill-Reihe als Filter, default
    auf aktives Projekt wenn vorhanden, sonst "Alle Projekte".
  - Diagnostic: zweites Dropdown im Files-Tab, baut Projekt-Optionen
    dynamisch aus /api/brain/projects/list.

Bewusst nicht drin (Folgeschritt):
- Per-File "Projekt zuweisen"-Action (Long-Press / Right-Click)
- Filter-Sync zwischen ChatScreen-Banner und SettingsScreen-Filter

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:55:02 +02:00
parent 1baa1a7a08
commit 1fb512c2fd
7 changed files with 359 additions and 22 deletions
+161 -15
View File
@@ -70,6 +70,9 @@ interface ChatMessage {
text: string;
timestamp: number;
attachments?: Attachment[];
/** Projekt-Zuordnung — leer = Hauptchat. Wird genutzt um Bubbles zu
* Projekt-Bloecken zu gruppieren (auf/einklappbar). */
projectId?: string;
/** Bridge-Message-ID zur Zuordnung von TTS-Audio */
messageId?: string;
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
@@ -284,6 +287,8 @@ const ChatScreen: React.FC = () => {
const [searchVisible, setSearchVisible] = useState(false);
const [projectsVisible, setProjectsVisible] = useState(false);
const [activeProject, setActiveProject] = useState<BrainProject | null>(null);
// Lookup-Map id → Projekt — fuer Header-Names im Chat-Verlauf
const [projectNameById, setProjectNameById] = useState<Record<string, string>>({});
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: ''});
@@ -466,7 +471,14 @@ const ChatScreen: React.FC = () => {
useEffect(() => {
const loadProject = () => {
brainApi.getProjectStatus()
.then(s => setActiveProject(s.active || null))
.then(s => {
setActiveProject(s.active || null);
// Lookup-Map fuellen damit der Chat-Verlauf Header mit Namen rendern kann
const map: Record<string, string> = {};
for (const p of (s.projects || [])) map[p.id] = p.name;
if (s.active) map[s.active.id] = s.active.name;
setProjectNameById(prev => ({ ...prev, ...map }));
})
.catch(() => {});
};
loadProject();
@@ -1072,6 +1084,7 @@ const ChatScreen: React.FC = () => {
attachments: message.payload.attachments as Attachment[] | undefined,
messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || undefined,
projectId: ((message.payload as any).projectId as string) || '',
};
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
@@ -1614,7 +1627,87 @@ const ChatScreen: React.FC = () => {
() => messages.filter(m => !m.memorySaved && !m.triggerCreated && !m.skillCreated),
[messages],
);
const invertedMessages = useMemo(() => [...chatVisibleMessages].reverse(), [chatVisibleMessages]);
// Projekt-Bloecke: aufeinanderfolgende Nachrichten mit gleicher projectId
// werden visuell gruppiert. Erstes Message-Item einer Gruppe bekommt einen
// Header darueber (tappable für collapse). Collapsed → restliche Messages
// der Gruppe werden ausgefiltert; nur Header bleibt sichtbar.
// Hauptchat-Nachrichten (projectId leer) bleiben ungruppiert dazwischen.
const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(new Set());
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<string>();
const counts = new Map<string, number>();
let lastPid: string | null = null;
for (const m of chatVisibleMessages) {
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 };
}, [chatVisibleMessages]);
// 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.
const messagesForRender = useMemo(() => {
return chatVisibleMessages.filter(m => {
const pid = m.projectId || '';
if (!pid) return true;
if (!collapsedProjects.has(pid)) return true;
return projectMeta.firstOfGroup.has(m.id);
});
}, [chatVisibleMessages, collapsedProjects, projectMeta]);
// Auto-Collapse beim Projekt-Wechsel: altes Projekt einklappen, neues aufklappen.
const prevActiveIdRef = useRef<string>('');
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<string>();
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;
});
}, []);
const invertedMessages = useMemo(() => [...messagesForRender].reverse(), [messagesForRender]);
// Such-Treffer: alle Message-IDs die zur Query passen. NEUESTE ZUERST —
// analog zu WhatsApp/Telegram: User ist visuell unten im Chat, der erste
@@ -1816,6 +1909,7 @@ const ChatScreen: React.FC = () => {
const location = await getCurrentLocation();
const cmid = nextClientMsgId();
const activePid = activeProject?.id || '';
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
@@ -1824,16 +1918,18 @@ const ChatScreen: React.FC = () => {
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
projectId: activePid,
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s',
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
dispatchWithAck(cmid, 'chat', {
text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
projectId: activePid,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
@@ -2332,6 +2428,44 @@ 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 = (
<TouchableOpacity
onPress={() => 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,
}}
>
<Text style={{ fontSize: 14, color: '#34C759', fontWeight: '700', flex: 1 }} numberOfLines={1}>
{isCollapsed ? '▶' : '▼'} 📁 {projectName}
</Text>
<Text style={{ fontSize: 11, color: '#8888AA' }}>
{projectCount} {projectCount === 1 ? 'Nachricht' : 'Nachrichten'}
</Text>
</TouchableOpacity>
);
if (isCollapsed) return header;
return (
<View>
{header}
{renderMessage({ item })}
</View>
);
};
// 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.
@@ -2483,9 +2617,8 @@ const ChatScreen: React.FC = () => {
);
})()}
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt, Tap öffnet Liste */}
<TouchableOpacity
onPress={() => setProjectsVisible(true)}
{/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */}
<View
style={{
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 12, paddingVertical: 6,
@@ -2494,13 +2627,26 @@ const ChatScreen: React.FC = () => {
borderColor: activeProject ? '#34C759' : '#1E1E2E',
}}
>
<Text style={{ fontSize: 13, color: activeProject ? '#34C759' : '#8888AA', fontWeight: activeProject ? '700' : '500', flex: 1 }} numberOfLines={1}>
{activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'}
</Text>
<Text style={{ fontSize: 11, color: '#555570' }}>
{activeProject ? 'wechseln ' : 'Projekte '}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setProjectsVisible(true)} style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 13, color: activeProject ? '#34C759' : '#8888AA', fontWeight: activeProject ? '700' : '500', flex: 1 }} numberOfLines={1}>
{activeProject ? `📁 ${activeProject.name}` : '💬 Hauptchat'}
</Text>
<Text style={{ fontSize: 11, color: '#555570', marginRight: 8 }}>
{activeProject ? 'wechseln ' : 'Projekte '}
</Text>
</TouchableOpacity>
{activeProject && (
<TouchableOpacity
onPress={() => {
brainApi.switchProject('').then(s => setActiveProject(s.active || null)).catch(() => {});
}}
style={{ paddingHorizontal: 8, paddingVertical: 2, borderRadius: 4, backgroundColor: 'rgba(255,255,255,0.08)' }}
hitSlop={{top:8,bottom:8,left:8,right:8}}
>
<Text style={{ fontSize: 11, color: '#E0E0F0', fontWeight: '600' }}>× Hauptchat</Text>
</TouchableOpacity>
)}
</View>
{/* Projekt-Modal */}
<ProjectsBrowser
@@ -2591,7 +2737,7 @@ const ChatScreen: React.FC = () => {
}, 300);
}}
keyExtractor={item => item.id}
renderItem={renderMessage}
renderItem={renderMessageWithProjectHeader}
contentContainerStyle={styles.messageList}
showsVerticalScrollIndicator={false}
ListEmptyComponent={