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
+44 -1
View File
@@ -1005,6 +1005,37 @@ class ARIABridge:
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
return cleaned, files, missing
def _tag_file_to_active_project(self, file_path: str) -> None:
"""Holt vom Brain das aktive Projekt + schreibt file_path → project_id
in /shared/config/file_projects.json. Best-effort, fail-silent.
Wird vom File-Save-Handler nach erfolgreichem Schreiben aufgerufen."""
try:
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
with urllib.request.urlopen(f"{brain_url}/projects/status", timeout=5) as r:
data = json.loads(r.read())
active_id = (data.get("active_id") or "").strip()
if not active_id:
return
manifest_path = "/shared/config/file_projects.json"
os.makedirs("/shared/config", exist_ok=True)
try:
with open(manifest_path) as f:
manifest = json.load(f)
if not isinstance(manifest, dict):
manifest = {}
except FileNotFoundError:
manifest = {}
except Exception:
manifest = {}
manifest[file_path] = active_id
tmp = manifest_path + ".tmp"
with open(tmp, "w") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
os.replace(tmp, manifest_path)
logger.info("[file-project] %s%s", file_path, active_id)
except Exception as exc:
logger.warning("[file-project] tag failed (%s): %s", file_path, exc)
async def _broadcast_aria_file(self, file_info: dict) -> None:
"""ARIA hat eine Datei fuer den User erstellt — App+Diagnostic informieren."""
logger.info("[rvs] ARIA-Datei rausgeben: %s (%s, %dKB)",
@@ -1224,6 +1255,9 @@ class ARIABridge:
"backupTs": assistant_backup_ts,
# Debug: aufbereiteter Text fuer TTS (App ignoriert, Diagnostic zeigt optional)
"ttsText": tts_text_preview if tts_text_preview != text else "",
# Projekt-Zuordnung — App + Diagnostic sortieren die Bubble in
# den passenden Projekt-Block. Leer = Hauptchat.
"projectId": (payload.get("projectId") or "") if isinstance(payload, dict) else "",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
@@ -1521,6 +1555,11 @@ class ARIABridge:
await self._emit_activity("idle", "")
return
# Projekt-Kontext des Turns — wird an _process_core_response weiter-
# gegeben damit der chat-Broadcast die Bubble dem richtigen Projekt-
# Block in App + Diagnostic zuordnen kann.
turn_project_id = (data.get("project_id") or "").strip()
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen
for event in data.get("events", []) or []:
@@ -1586,7 +1625,7 @@ class ARIABridge:
# passend behandelt wird (hier minimal, weil Brain noch keine
# metadata mitschickt).
try:
await self._process_core_response(reply, {})
await self._process_core_response(reply, {"projectId": turn_project_id})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "")
@@ -2125,6 +2164,10 @@ class ARIABridge:
f.write(base64.b64decode(file_b64))
size_kb = len(file_b64) // 1365
logger.info("[rvs] Datei gespeichert: %s (%dKB)", file_path, size_kb)
# Datei dem aktuellen Projekt zuordnen (falls Stefan in einem ist).
# Manifest in /shared/config/file_projects.json — File-Manager
# in App + Diagnostic filtert danach.
self._tag_file_to_active_project(file_path)
# Pixel-Bilder fuer Claude-Vision shrinken wenn > 2 MB. SVG/PDF/ZIP
# bleiben unangetastet (Vision laeuft eh nur auf Raster-Formaten).