From f51ad1547ddb06b2a9705f0af81119a6f444166e Mon Sep 17 00:00:00 2001 From: duffyduck Date: Tue, 16 Jun 2026 09:36:11 +0200 Subject: [PATCH] fix(projects): project_id im Chat-Backup persistieren + 1 Block pro Projekt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Stefan-Reports nach dem ersten Live-Test: 1. App-Reload verlor die Projekt-Bloecke - chat_backup.jsonl hatte keine project_id-Felder, also kamen die Bubbles als Hauptchat zurueck wenn die App ueber chat_history_response ihre History neu lud. - Fix: aria_bridge schreibt jetzt project_id in jeden Backup-Eintrag. Assistant-Reply via turn_pid (aus ChatOut.project_id); User-Message via payload.projectId (oder Brain-Status-Query als Fallback fuer Trigger-Replies / Diagnostic-Sends). - App: chat_history_response-Mapper liest m.project_id → ChatMessage.projectId. 2. Raus + rein in ein Projekt erzeugte einen zweiten Block am Ende - Vorher: Gruppierung bei aufeinanderfolgenden gleich-getaggten Bubbles. Hauptchat dazwischen hat den Block "unterbrochen", neuer Block. - Fix: neue reorderedMessages-Stufe sortiert Messages so um, dass alle eines Projekts contiguous werden, verankert am LATEST-Activity- Timestamp des Projekts. Genau EIN Block pro projectId — bei Re-Enter wandert der existierende Block ans Zeitende der Liste, die neue Bubble haengt unten in der Gruppe. - Hauptchat-Bubbles bleiben chronologisch zwischen den Projekt-Blöcken. Co-Authored-By: Claude Opus 4.7 --- android/src/screens/ChatScreen.tsx | 51 ++++++++++++++++++++++++------ bridge/aria_bridge.py | 18 +++++++++-- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 9545317..f37c07b 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -769,6 +769,7 @@ const ChatScreen: React.FC = () => { timestamp: m.ts || Date.now(), attachments: attachments.length ? attachments : undefined, backupTs: typeof m.ts === 'number' ? m.ts : undefined, + projectId: typeof m.project_id === 'string' ? m.project_id : '', ...(cmid && { clientMsgId: cmid }), // Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓) ...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }), @@ -1628,39 +1629,71 @@ const ChatScreen: React.FC = () => { [messages], ); - // 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. + // 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 chatVisibleMessages) { + 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 }; - }, [chatVisibleMessages]); + }, [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. const messagesForRender = useMemo(() => { - return chatVisibleMessages.filter(m => { + return reorderedMessages.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]); + }, [reorderedMessages, collapsedProjects, projectMeta]); // Auto-Collapse beim Projekt-Wechsel: altes Projekt einklappen, neues aufklappen. const prevActiveIdRef = useRef(''); diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 3ff04da..d9951a9 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -1237,6 +1237,7 @@ class ARIABridge: "text": display_text, "files": [{"serverPath": f["serverPath"], "name": f["name"], "mimeType": f["mimeType"], "size": f["size"]} for f in aria_files], + "project_id": turn_pid, }) metadata = payload.get("metadata", {}) @@ -1509,7 +1510,9 @@ class ARIABridge: asyncio.create_task(self.send_to_core(text, source="app-file+chat")) return True - async def send_to_core(self, text: str, source: str = "bridge", client_msg_id: Optional[str] = None) -> None: + async def send_to_core(self, text: str, source: str = "bridge", + client_msg_id: Optional[str] = None, + project_id: str = "") -> None: """Sendet Text an aria-brain (HTTP /chat) und broadcastet die Antwort. Nicht-Streaming: wir warten bis Brain fertig ist, dann pushen wir @@ -1520,15 +1523,25 @@ class ARIABridge: brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080") url = f"{brain_url}/chat" payload = json.dumps({"message": text, "source": source}).encode("utf-8") - logger.info("[brain] chat ← %s '%s'", source, text[:80]) + logger.info("[brain] chat ← %s '%s' project=%s", source, text[:80], project_id or "(main)") # User-Nachricht in chat_backup.jsonl loggen — wird beim App-Reconnect # / Diagnostic-Reload als History-Quelle gelesen. clientMsgId speichern # damit die App beim chat_history_response ihre lokale Bubble # dedupen kann (sonst verschwindet sie nach Offline→Online-Race). + # project_id: pre-turn-State (was App geschickt hat). Wenn leer, vom + # Brain-Status nachholen — z.B. bei Trigger-Replies oder Diagnostic-Send. entry: dict = {"role": "user", "text": text, "source": source} if client_msg_id: entry["clientMsgId"] = client_msg_id + if not project_id: + try: + with urllib.request.urlopen(f"{brain_url}/projects/status", timeout=3) as r: + project_id = (json.loads(r.read()).get("active_id") or "") + except Exception: + pass + if project_id: + entry["project_id"] = project_id self._append_chat_backup(entry) # agent_activity → thinking. _emit_activity statt direktem _send_to_rvs @@ -1957,6 +1970,7 @@ class ARIABridge: core_text, source="app" + (" [barge-in]" if interrupted else ""), client_msg_id=client_msg_id, + project_id=str(payload.get("projectId") or ""), )) return