From c5ea8cbb6344124003ddf675fd68c042d8035583 Mon Sep 17 00:00:00 2001 From: Aissa Date: Sun, 16 Aug 2026 22:40:32 +0000 Subject: [PATCH] Ship War Room UX, CEO inbox, mobile chat, and Agents NAS workspace. Make A2A togglable, keep reply context while reading, route agent data to //10.4.7.11/share/Agents, and polish themes plus export-intel sync cancel. --- cockpit/app/routes/agents.py | 4 +- cockpit/app/routes/agents_api.py | 48 +- cockpit/app/routes/export_intel.py | 5 + cockpit/app/services/agent_chat.py | 406 ++++++++++-- cockpit/app/services/agent_nas.py | 139 ++++ cockpit/app/services/agent_terminal.py | 27 +- cockpit/static/css/agent-characters.css | 21 + cockpit/static/css/agents-warroom.css | 293 ++++++++- cockpit/static/css/app-sidebar-neo.css | 35 +- cockpit/static/css/export-intel.css | 8 + cockpit/static/css/flk-themes.css | 681 ++++++++++++++++---- cockpit/static/css/mobile.css | 253 ++++++++ cockpit/static/css/vertical-tabs.css | 52 ++ cockpit/static/js/agent-avatars.js | 109 ++-- cockpit/static/js/agents-warroom.js | 576 +++++++++++++++-- cockpit/static/js/export-intel.js | 38 +- cockpit/templates/agents.html | 174 ++++- cockpit/templates/agents_warroom_popup.html | 20 +- cockpit/templates/base.html | 157 ++++- cockpit/templates/export_intel.html | 17 +- cockpit/templates/settings.html | 141 +++- docker-compose.yml | 5 +- migrations/045_warroom_ceo_inbox.sql | 18 + tools-api/app/export_intel.py | 14 + tools-api/app/export_intel_sync.py | 13 + tools-api/app/export_intel_sync_progress.py | 44 +- 26 files changed, 2891 insertions(+), 407 deletions(-) create mode 100644 cockpit/app/services/agent_nas.py create mode 100644 migrations/045_warroom_ceo_inbox.sql diff --git a/cockpit/app/routes/agents.py b/cockpit/app/routes/agents.py index 04b3b8c..ca3bec1 100644 --- a/cockpit/app/routes/agents.py +++ b/cockpit/app/routes/agents.py @@ -21,9 +21,9 @@ AGENT_STATUSES = [ @router.get("") async def agents_page(request: Request): - active_tab = request.query_params.get("tab", "souls") + active_tab = request.query_params.get("tab", "warroom") if active_tab not in {"souls", "mesh", "terminals", "approvals", "warroom"}: - active_tab = "souls" + active_tab = "warroom" events: list = [] try: events = fetch_all( diff --git a/cockpit/app/routes/agents_api.py b/cockpit/app/routes/agents_api.py index e93c80f..6c13d61 100644 --- a/cockpit/app/routes/agents_api.py +++ b/cockpit/app/routes/agents_api.py @@ -551,6 +551,7 @@ def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str class WarroomMessageBody(BaseModel): content: str = Field("", max_length=8000) attachments: list[dict] = Field(default_factory=list) + reply_to_id: Optional[int] = None @router.get("/warroom/rooms") @@ -575,9 +576,12 @@ async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int | from app.services import agent_chat agent_chat.ensure_warroom() + room = agent_chat.get_room(room_id) + if not room: + raise HTTPException(404, "Room niet gevonden") items = agent_chat.list_messages(room_id, limit=min(limit, 300), after_id=after_id) deleted_ids = agent_chat.list_deleted_ids(room_id) if after_id else [] - return {"ok": True, "items": items, "deleted_ids": deleted_ids} + return {"ok": True, "room": room, "items": items, "deleted_ids": deleted_ids} @router.post("/warroom/rooms/{room_id}/messages") @@ -592,6 +596,7 @@ async def warroom_post_message(request: Request, room_id: int, body: WarroomMess role=(user.get("role") if isinstance(user, dict) else None), sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None), attachments=body.attachments, + reply_to_id=body.reply_to_id, ) except ValueError as exc: raise HTTPException(400, str(exc)) from exc @@ -615,6 +620,7 @@ async def warroom_post_message_stream(request: Request, room_id: int, body: Warr role=(user.get("role") if isinstance(user, dict) else None), sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None), attachments=body.attachments, + reply_to_id=body.reply_to_id, ): yield f"data: {json.dumps(ev, default=str)}\n\n" except ValueError as exc: @@ -715,6 +721,46 @@ async def warroom_continue(request: Request, room_id: int, turns: int = 2): + + +@router.get("/warroom/ceo-inbox") +async def warroom_ceo_inbox(unread: bool = False, limit: int = 50) -> dict[str, Any]: + """Direct messages from agents to CEO (@aissa).""" + from app.services import agent_chat + + items = agent_chat.list_ceo_inbox(unread_only=unread, limit=min(limit, 100)) + return { + "ok": True, + "items": items, + "unread": agent_chat.ceo_inbox_unread_count(), + } + + +class CeoInboxReadBody(BaseModel): + ids: Optional[list[int]] = None + all: bool = False + + +@router.post("/warroom/ceo-inbox/read") +async def warroom_ceo_inbox_read(body: CeoInboxReadBody) -> dict[str, Any]: + from app.services import agent_chat + + n = agent_chat.mark_ceo_inbox_read(body.ids, all_unread=bool(body.all)) + return {"ok": True, "marked": n, "unread": agent_chat.ceo_inbox_unread_count()} + + +@router.delete("/warroom/rooms/{room_id}/messages") +async def warroom_clear_messages(room_id: int) -> dict[str, Any]: + """Soft-delete all messages in a War Room / DM.""" + from app.services import agent_chat + + room = agent_chat.get_room(room_id) + if not room: + raise HTTPException(404, "Room niet gevonden") + n = agent_chat.clear_messages(room_id) + return {"ok": True, "cleared": n} + + @router.delete("/warroom/rooms/{room_id}/messages/{message_id}") async def warroom_delete_message(room_id: int, message_id: int) -> dict[str, Any]: """Soft-delete a War Room message.""" diff --git a/cockpit/app/routes/export_intel.py b/cockpit/app/routes/export_intel.py index 440d961..316a7cf 100644 --- a/cockpit/app/routes/export_intel.py +++ b/cockpit/app/routes/export_intel.py @@ -305,6 +305,11 @@ async def api_sync_places(request: Request): return r.json() + +@router.post("/api/export-intel/sync/cancel") +async def api_sync_cancel(): + return await _proxy("POST", "/sync/cancel") + @router.get("/api/export-intel/sync/status") async def api_sync_status(): return await _proxy("GET", "/sync/status") diff --git a/cockpit/app/services/agent_chat.py b/cockpit/app/services/agent_chat.py index aa30f56..17e2aef 100644 --- a/cockpit/app/services/agent_chat.py +++ b/cockpit/app/services/agent_chat.py @@ -9,7 +9,7 @@ from typing import Any from app.config import settings from app.db import execute, fetch_all, fetch_one -from app.services import agent_souls, hermes_memory, llm_router +from app.services import agent_nas, agent_souls, hermes_memory, llm_router from app.services.agent_names import normalize_agent_key log = logging.getLogger("cockpit.agent_chat") @@ -20,7 +20,7 @@ MAX_REPLIES_PER_ROUND = 3 HISTORY_LIMIT = 16 WARROOM_MAX_TOKENS = 512 WARROOM_TIMEOUT = 55.0 -A2A_CONTINUE_MAX = 3 +A2A_CONTINUE_MAX = 1 COCKPIT_SNAPSHOT_TTL = 60.0 @@ -132,6 +132,14 @@ def _iso(row: dict[str, Any] | None) -> dict[str, Any] | None: return out +def _bootstrap_agents_nas() -> None: + try: + keys = [str(s.get("agent_key")) for s in agent_souls.list_souls()] + agent_nas.bootstrap_all(keys) + except Exception: + log.exception("agents NAS bootstrap failed") + + def ensure_warroom() -> dict[str, Any]: row = fetch_one("SELECT * FROM warroom_rooms WHERE kind = 'warroom' LIMIT 1") if row: @@ -173,6 +181,18 @@ def list_rooms() -> list[dict[str, Any]]: return [_iso(r) for r in rows] # type: ignore +def get_message(message_id: int, room_id: int | None = None) -> dict[str, Any] | None: + if room_id is not None: + return _iso(fetch_one( + "SELECT * FROM warroom_messages WHERE id = %s AND room_id = %s AND deleted_at IS NULL", + (message_id, room_id), + )) + return _iso(fetch_one( + "SELECT * FROM warroom_messages WHERE id = %s AND deleted_at IS NULL", + (message_id,), + )) + + def get_room(room_id: int) -> dict[str, Any] | None: return _iso(fetch_one("SELECT * FROM warroom_rooms WHERE id = %s", (room_id,))) @@ -213,6 +233,21 @@ def delete_message(room_id: int, message_id: int) -> dict[str, Any] | None: return _iso(row) if row else None # type: ignore + +def clear_messages(room_id: int) -> int: + """Soft-delete all messages in a room. Returns count cleared.""" + rows = fetch_all( + """UPDATE warroom_messages + SET deleted_at = NOW(), + content = '', + metadata = COALESCE(metadata, '{}'::jsonb) || '{"deleted": true, "cleared": true}'::jsonb + WHERE room_id = %s AND deleted_at IS NULL + RETURNING id""", + (room_id,), + ) + execute("UPDATE warroom_rooms SET updated_at = NOW() WHERE id = %s", (room_id,)) + return len(rows or []) + def list_deleted_ids(room_id: int) -> list[int]: """Recently soft-deleted message ids (for live UI sync across clients).""" rows = fetch_all( @@ -260,6 +295,121 @@ def _active_agent_keys() -> set[str]: return {str(s.get("agent_key")) for s in agent_souls.list_souls() if s.get("is_active", True)} + +CEO_ALIASES = {"aissa", "ceo", "eigenaar", "ervoorz"} # ervoorz = legacy alias +CEO_MENTION_RE = re.compile(r"@([A-Za-z0-9_\-]{2,40})", re.I) + + +def parse_ceo_mentions(text: str) -> bool: + """True if message is directed at CEO via @aissa (legacy: @ervoorz / @ceo).""" + for m in CEO_MENTION_RE.findall(text or ""): + if m.lower() in CEO_ALIASES: + return True + return False + + +def _ceo_excerpt(text: str, limit: int = 280) -> str: + t = (text or "").strip() + if len(t) <= limit: + return t + return t[: limit - 1] + "…" + + +def record_ceo_direct(message_row: dict[str, Any] | None) -> dict[str, Any] | None: + """If an agent message @mentions CEO, store in ceo inbox.""" + if not message_row or message_row.get("sender_type") != "agent": + return None + content = message_row.get("content") or "" + if not parse_ceo_mentions(content): + return None + ensure_warroom() + # ensure table exists (idempotent) + try: + execute( + """CREATE TABLE IF NOT EXISTS warroom_ceo_inbox ( + id BIGSERIAL PRIMARY KEY, + message_id BIGINT, + room_id BIGINT, + agent_key VARCHAR(64), + content TEXT NOT NULL, + excerpt TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + read_at TIMESTAMPTZ, + recipient TEXT NOT NULL DEFAULT 'aissa' + )""" + ) + except Exception: + pass + row = fetch_one( + """INSERT INTO warroom_ceo_inbox (message_id, room_id, agent_key, content, excerpt, recipient) + VALUES (%s, %s, %s, %s, %s, 'aissa') + RETURNING *""", + ( + message_row.get("id"), + message_row.get("room_id"), + message_row.get("agent_key"), + content, + _ceo_excerpt(content), + ), + ) + return _iso(row) if row else None + + +def list_ceo_inbox(*, unread_only: bool = False, limit: int = 50) -> list[dict[str, Any]]: + ensure_warroom() + try: + if unread_only: + rows = fetch_all( + """SELECT i.*, s.display_name AS agent_display_name + FROM warroom_ceo_inbox i + LEFT JOIN agent_souls s ON s.agent_key = i.agent_key + WHERE i.recipient = 'aissa' AND i.read_at IS NULL + ORDER BY i.created_at DESC LIMIT %s""", + (limit,), + ) + else: + rows = fetch_all( + """SELECT i.*, s.display_name AS agent_display_name + FROM warroom_ceo_inbox i + LEFT JOIN agent_souls s ON s.agent_key = i.agent_key + WHERE i.recipient = 'aissa' + ORDER BY i.created_at DESC LIMIT %s""", + (limit,), + ) + except Exception: + return [] + return [_iso(r) for r in (rows or [])] # type: ignore + + +def ceo_inbox_unread_count() -> int: + try: + row = fetch_one( + """SELECT COUNT(*) AS n FROM warroom_ceo_inbox + WHERE recipient = 'aissa' AND read_at IS NULL""" + ) + return int((row or {}).get("n") or 0) + except Exception: + return 0 + + +def mark_ceo_inbox_read(ids: list[int] | None = None, *, all_unread: bool = False) -> int: + if all_unread: + rows = fetch_all( + """UPDATE warroom_ceo_inbox SET read_at = NOW() + WHERE recipient = 'aissa' AND read_at IS NULL + RETURNING id""" + ) + return len(rows or []) + if not ids: + return 0 + rows = fetch_all( + """UPDATE warroom_ceo_inbox SET read_at = NOW() + WHERE recipient = 'aissa' AND id = ANY(%s) AND read_at IS NULL + RETURNING id""", + (ids,), + ) + return len(rows or []) + def parse_mentions(text: str) -> list[str]: keys = _active_agent_keys() found: list[str] = [] @@ -327,7 +477,7 @@ def _soul_system(soul: dict[str, Any], *, room_kind: str) -> str: name = soul.get("display_name") or soul.get("agent_key") key = soul.get("agent_key") role = soul.get("role_title") or "agent" - soul_md = (soul.get("soul_md") or "")[:700] + soul_md = (soul.get("soul_md") or "")[:1600] resp = (soul.get("responsibilities") or "")[:350] roster = _live_roster_line() hard = ( @@ -353,22 +503,35 @@ def _soul_system(soul: dict[str, Any], *, room_kind: str) -> str: "- Jij bent orchestrator: bij feitelijke retail/CRM-vragen eerst @retail of @bizdev laten antwoorden, " "of alleen samenvatten wat zij/data al zeiden. Liever geen antwoord dan een verkeerd antwoord.\n" ) - extra = ( - accuracy - + "War Room-regels:\n" - "- Max 4 korte zinnen, Nederlands.\n" - "- Geen @zelf-prefix / geen herhalingen.\n" - "- Peer @mentions oké; zeg nooit dat agents uit staan.\n" - f"{snap}\n" - if room_kind == "warroom" - else accuracy + "1:1 DM: bondig NL. Alleen zekere cockpit-feiten.\n" - ) + if room_kind == "warroom": + extra = ( + accuracy + + "War Room-regels:\n" + + "- Max 4 korte zinnen, Nederlands.\n" + + "- Geen @zelf-prefix / geen herhalingen.\n" + + "- Peer @mentions verplicht: eindig met @iemand + gerichte vraag of reactie.\n" + + "- Zeg nooit dat agents uit staan.\n" + + "- Menselijk & echt: reageer concreet op de vorige spreker (feit, mop of vraag).\n" + + "- Lichte food-moppen / Foodlinkk-humor mag (respectvol, geen geloof/politiek/grof).\n" + + "- Mix: meestal inhoud, af en toe 1 mop — daarna weer professioneel.\n" + + "- Na een mop van een collega: lach kort terug of bouw erop voort, en @mention door.\n" + + "- CEO heet Aissa. Spreek hem normaal aan met 'Aissa' (of je/jij).\n" + + "- Volg Aissa's laatste input als prioriteit. Bouw daarop voort, niet ernaast.\n" + + "- Escalatie naar CEO-inbox: gebruik @aissa + concrete vraag/actie (alleen als echt nodig).\n" + + "- Goed: 'Aissa, hier is de status…' of '@aissa we hebben jouw go nodig voor X'.\n" + + "- Gebruik NOOIT @ervoorz (verouderd/verwarrend).\n" + + "- Nodig collega's uit: @mention wie iets kan toevoegen (finance/bizdev/retail/…).\n" + + f"{snap}\n" + ) + else: + extra = accuracy + "1:1 DM: bondig NL. Alleen zekere cockpit-feiten.\n" + return ( f"{hard}\n" f"Je bent {name} (@{key}), {role} bij Foodlinkk.\n" f"Spreek ALLEEN als {name} — niet als een andere agent.\n" f"Focus: {resp or '—'}\n" - f"{soul_md}\n\n{extra}" + f"{soul_md}\n\n{extra}\n\n{agent_nas.memory_rule_text()}"f"- Jouw NAS-werkmap: {agent_nas.agent_paths(str(key))['smb']}\n" ) @@ -426,6 +589,53 @@ _ALIAS_MAP = { } + +def _topic_contributors( + text: str, + *, + exclude: set[str] | None = None, + limit: int = 2, +) -> list[str]: + """Pick agents who can meaningfully add to the topic (beyond Herman).""" + exclude = set(exclude or set()) + active = _active_agent_keys() + low = (text or "").lower() + rules: list[tuple[tuple[str, ...], str]] = [ + (("omzet", "marge", "kosten", "factuur", "cash", "btw", "budget", "winst", "prijs"), "finance"), + (("klant", "deal", "pipeline", "lead", "offerte", "sales", "partner", "b2b"), "bizdev"), + (("retail", "supermarkt", "schap", "ah ", "jumbo", "plus ", "lidl", "listing"), "retail"), + (("campagne", "merk", "content", "social", "branding", "advert"), "marketing"), + (("sku", "assortiment", "recept", "product", "formul"), "product"), + (("inkoop", "leverancier", "sourcing", "moq", "supplier"), "sourcing"), + (("halal", "certific", "kosher"), "halal"), + (("verpakking", "label", "packaging", "design"), "packaging"), + (("onderzoek", "marktanalyse", "research", "concurrent"), "research"), + (("hr", "personeel", "werving", "team"), "hr"), + (("sysops", "server", "deploy", "infra", "bug"), "sysops"), + (("web", "site", "landing", "seo"), "webbuilder"), + (("kennis", "document", "wiki"), "knowledge"), + (("email", "mail", "newsletter"), "email"), + (("browser", "scrape", "website check"), "browser"), + ] + picked: list[str] = [] + for words, key in rules: + if key in exclude or key not in active or key in picked: + continue + if any(w in low for w in words): + picked.append(key) + if len(picked) >= limit: + return picked + # fallback: rotate through core team not yet speaking + core = ["bizdev", "finance", "retail", "marketing", "product", "sourcing", "research"] + for key in core: + if key in exclude or key not in active or key in picked: + continue + picked.append(key) + if len(picked) >= limit: + break + return picked + + def resolve_speakers(text: str, *, default_herman: bool = True) -> list[str]: """@mentions + soft name matches (bizdev/finance zonder @).""" active = _active_agent_keys() @@ -459,6 +669,11 @@ def resolve_speakers(text: str, *, default_herman: bool = True) -> list[str]: # Alleen zonder @mentions: "meeting met jou en bizdev" → Herman erbij if re.search(r"\b(jou|jullie|samen|meeting|overleg|bellen|call|afspraak)\b", low): found.insert(0, "herman") + # Zonder expliciete @mentions: laat 1–2 relevante agents meedoen naast Herman + if found and not explicit: + for extra in _topic_contributors(text, exclude=set(found), limit=2): + if extra not in found: + found.append(extra) return found[:MAX_REPLIES_PER_ROUND] @@ -720,6 +935,7 @@ async def post_user_message( role: str | None = None, sender_avatar_url: str | None = None, attachments: list[dict[str, Any]] | None = None, + reply_to_id: int | None = None, ) -> dict[str, Any]: text = (content or "").strip() files = _normalize_attachments(attachments) @@ -733,6 +949,19 @@ async def post_user_message( meta: dict[str, Any] = {"hermes_user": hermes_user} if files: meta["attachments"] = files + if reply_to_id: + parent = get_message(int(reply_to_id), room_id) + if parent: + excerpt = (parent.get("content") or "").strip() + if len(excerpt) > 220: + excerpt = excerpt[:219] + "…" + meta["reply_to"] = { + "id": parent.get("id"), + "agent_key": parent.get("agent_key"), + "username": parent.get("username"), + "sender_type": parent.get("sender_type"), + "excerpt": excerpt, + } user_msg = _insert_message( room_id, @@ -841,17 +1070,28 @@ async def _agent_speak_stream( chunks.append(delta) yield ("token", {"agent_key": agent_key, "delta": delta}) reply = _sanitize_warroom_reply(agent_key, "".join(chunks)) + meta = { + "llm": {"provider_type": "hermes", "hermes_user": agent_user, "streamed": True}, + "in_reply_to": in_reply_to, + "model": "hermes-agent/mimo", + } + if parse_ceo_mentions(reply): + meta["ceo_direct"] = True + meta["ceo_alias"] = "aissa" msg = _insert_message( int(room["id"]), sender_type="agent", agent_key=agent_key, content=reply, - metadata={ - "llm": {"provider_type": "hermes", "hermes_user": agent_user, "streamed": True}, - "in_reply_to": in_reply_to, - "model": "hermes-agent/mimo", - }, + metadata=meta, ) + try: + inbox = record_ceo_direct(msg) + if inbox: + msg = dict(msg) + msg["ceo_inbox_id"] = inbox.get("id") + except Exception: + log.exception("ceo inbox record failed") yield ("done", msg) except Exception as exc: log.exception("agent stream failed %s", agent_key) @@ -872,6 +1112,7 @@ async def post_user_message_stream( role: str | None = None, sender_avatar_url: str | None = None, attachments: list[dict[str, Any]] | None = None, + reply_to_id: int | None = None, ): """Async generator of SSE-ready event dicts for live typing.""" text = (content or "").strip() @@ -886,6 +1127,22 @@ async def post_user_message_stream( meta: dict[str, Any] = {"hermes_user": hermes_user} if files: meta["attachments"] = files + + parent = None + if reply_to_id: + parent = get_message(int(reply_to_id), room_id) + if parent: + excerpt = (parent.get("content") or "").strip() + if len(excerpt) > 220: + excerpt = excerpt[:219] + "…" + meta["reply_to"] = { + "id": parent.get("id"), + "agent_key": parent.get("agent_key"), + "username": parent.get("username"), + "sender_type": parent.get("sender_type"), + "excerpt": excerpt, + } + user_msg = _insert_message( room_id, sender_type="user", @@ -897,14 +1154,31 @@ async def post_user_message_stream( yield {"event": "user", "message": user_msg} trigger = _trigger_text(text, files) + if parent: + who = parent.get("agent_key") or parent.get("username") or "bericht" + excerpt = (meta.get("reply_to") or {}).get("excerpt") or "" + trigger = ( + f"CEO Aissa REPLY op bericht van @{who}:\n" + f'\"{excerpt}\"\n\n' + f"Antwoord van Aissa: {trigger}\n\n" + f"BELANGRIJK: reageer DIRECT op dit reply van Aissa. Geen losse A2A-chat." + ) + kind = room.get("kind") speakers: list[str] = [] if kind == "dm": key = room.get("agent_key") if key: speakers = [str(key)] + elif parent and parent.get("sender_type") == "agent" and parent.get("agent_key"): + # reply to agent → die agent eerst, max 1 extra contributor + primary = str(parent.get("agent_key")) + speakers = [primary] + for extra in _topic_contributors(text, exclude={primary}, limit=1): + speakers.append(extra) else: - speakers = resolve_speakers(trigger, default_herman=True) + # op user-bericht: max 2 speakers (Herman + 1 relevant) — geen storm + speakers = resolve_speakers(trigger, default_herman=True)[:2] produced: list[dict[str, Any]] = [] @@ -923,35 +1197,25 @@ async def post_user_message_stream( produced.append(payload) yield {"event": "system", "message": payload} + user_focus = ( + f"Prioriteit: beantwoord EERST het bericht van {username or 'Aissa'}. " + f"Spreek hem aan als Aissa. " + f"Voor escalatie naar CEO-inbox: @aissa + concrete vraag. Daarna optioneel 1 @mention naar een collega." + ) for key in speakers: - async for ev in _emit_speak(key, trig=trigger): + async for ev in _emit_speak(key, trig=f"{trigger}\n\n{user_focus}"): yield ev - # A2A rounds: agents @mention each other and answer with cockpit data - for _round in range(MAX_ROUNDS - 1): - nxt = _next_a2a_speakers(produced) - if not nxt: - # Force a light peer handoff so the room stays collaborative - last_agents = [m.get("agent_key") for m in produced if m.get("sender_type") == "agent"] - last = str(last_agents[-1]) if last_agents else (speakers[0] if speakers else "herman") - peers = [k for k in ("bizdev", "finance", "retail", "marketing", "herman") if k != last and k in _active_agent_keys()] - if not peers: - break - target = peers[_round % len(peers)] - nudge = ( - f"War Room topic van {username or 'user'}: {trigger}\n" - f"@{last} heeft gesproken. Jij bent @{target}. Stel 1 korte vraag aan een peer OF geef 1 cockpit-feit + @mention wie moet antwoorden." - ) - async for ev in _emit_speak(target, trig=nudge, reply_to=last): - yield ev - continue - for target, src in nxt: - follow = ( - f"War Room topic van {username or 'user'}: {trigger}\n" - f"@{src} noemde je. Antwoord met cockpit-data en @mention zo nodig een andere agent." - ) - async for ev in _emit_speak(target, trig=follow, reply_to=src): - yield ev + # Max 1 A2A follow-up — alleen bij expliciete @mention (geen geforceerde storm) + nxt = _next_a2a_speakers(produced) + for target, src in nxt[:1]: + follow = ( + f"Bericht van {username or 'Aissa'}: {text}\n" + f"@{src} noemde je. Reageer kort op @{src} EN blijf relevant voor Aissa's bericht. " + f"Geen ellenlange ketting." + ) + async for ev in _emit_speak(target, trig=follow, reply_to=src): + yield ev yield {"event": "done", "ok": True, "a2a": True} @@ -973,7 +1237,26 @@ async def continue_warroom_stream( hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel="warroom") recent = list_messages(room_id, limit=16) if not recent: - raise ValueError("Geen gesprek om te vervolgen") + # stil kanaal: toch A2A kickstarten via Herman + topic = "Open A2A check-in: korte status + lichte food-mop, daarna @mention een collega." + produced = [] + yield {"event": "continue_start", "topic": topic} + async for kind_ev, payload in _agent_speak_stream( + room, "herman", trigger=( + "Het is stil in de War Room. Start A2A: 1 korte professionele check-in " + "of milde food-mop. Spreek de CEO aan als Aissa; escalatie via @aissa. @mention @bizdev of @retail." + ), hermes_user=hermes_user, in_reply_to=None + ): + if kind_ev == "start": + yield {"event": "typing_start", **payload} + elif kind_ev == "token": + yield {"event": "typing", **payload} + elif kind_ev == "done": + yield {"event": "agent", "message": payload} + elif kind_ev == "error": + yield {"event": "system", "message": payload} + yield {"event": "done", "ok": True, "continued": True} + return # Find last user topic topic = "" @@ -982,7 +1265,7 @@ async def continue_warroom_stream( topic = (m.get("content") or "").strip() break if not topic: - topic = "Blijf het Foodlinkk cockpit-dossier bespreken (pipeline, deals, retail, finance)." + topic = "Volg Aissa's input als leidraad; spreek hem aan als Aissa; wissel cockpit-dossier met lichte humor; escaleer alleen met @aissa als echt nodig." produced = [m for m in recent if m.get("sender_type") == "agent"][-6:] yield {"event": "continue_start", "topic": topic[:200]} @@ -991,20 +1274,37 @@ async def continue_warroom_stream( nxt = _next_a2a_speakers(produced) if not nxt: last = str((produced[-1] or {}).get("agent_key") or "herman") if produced else "herman" - peers = [k for k in ("herman", "bizdev", "finance", "retail", "marketing", "product") if k != last and k in _active_agent_keys()] + spoken = {str(m.get("agent_key") or "") for m in produced} + spoken.add(last) + contrib = _topic_contributors(topic, exclude=spoken, limit=2) + peers = contrib or [ + k for k in ( + "herman", "bizdev", "finance", "retail", "marketing", + "product", "sourcing", "research", "halal", "packaging", + ) + if k not in spoken and k in _active_agent_keys() + ] + if not peers: + peers = [ + k for k in ("herman", "bizdev", "finance", "retail", "marketing", "product") + if k != last and k in _active_agent_keys() + ] if not peers: break target = peers[turn % len(peers)] src = last trig = ( - f"Autonoom War Room-overleg (user kan meelezen). Topic: {topic}\n" - f"Jij bent @{target}. Reageer op @{src} met 1 cockpit-feit en @mention 1 collega met een gerichte vraag." + f"Autonoom War Room A2A (user kan meelezen). Topic: {topic}\n" + f"Jij bent @{target}. Reageer ECHT op @{src}.\n" + f"Kies passend: (a) 1 cockpit-feit + gerichte vraag, OF (b) korte respectvolle food-mop + @mention terug naar de inhoud.\n" + f"Breng JOUW expertise in (niet herhalen wat @{src} zei). Spreek de CEO aan als Aissa. Altijd @mention 1 collega; escalatie naar CEO-inbox via @aissa. Max 4 zinnen. Natuurlijk NL." ) else: target, src = nxt[0] trig = ( - f"Autonoom War Room-overleg. Topic: {topic}\n" - f"@{src} vroeg jouw input. Antwoord met cockpit-data en @mention desnoods een andere agent." + f"Autonoom War Room A2A. Topic: {topic}\n" + f"@{src} sprak je aan. Reageer op die inhoud (of mop) alsof je in een echt teamoverleg zit.\n" + f"Geef waarde vanuit JOUW rol (feit/actie/vraag). Trek er 1 collega bij die iets kan toevoegen via @mention — niet alleen Herman. Korte food-humor mag." ) async for kind_ev, payload in _agent_speak_stream( diff --git a/cockpit/app/services/agent_nas.py b/cockpit/app/services/agent_nas.py new file mode 100644 index 0000000..eb89c4d --- /dev/null +++ b/cockpit/app/services/agent_nas.py @@ -0,0 +1,139 @@ +"""Agents NAS workspace — alle agent-data op //10.4.7.11/share/Agents.""" +from __future__ import annotations + +import json +import os +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +AGENTS_NAS_ROOT = Path(os.getenv("NAS_AGENTS_ROOT", "/data/nas-share/Agents")) +AGENTS_NAS_UNC = os.getenv("NAS_AGENTS_UNC", "//10.4.7.11/share/Agents") +AGENTS_NAS_SMB = os.getenv("NAS_AGENTS_SMB", "//10.4.7.11/share/Agents") + +AGENT_SUBDIRS = ("notes", "exports", "memory", "inbox", "outbox") + + +def slugify(name: str, max_len: int = 40) -> str: + s = re.sub(r"[^a-zA-Z0-9._-]+", "-", (name or "file").strip()).strip("-._") + return (s[:max_len] or "file") + + +def root() -> Path: + return AGENTS_NAS_ROOT + + +def unc() -> str: + return AGENTS_NAS_UNC + + +def smb() -> str: + return AGENTS_NAS_SMB + + +def ensure_root() -> Path: + AGENTS_NAS_ROOT.mkdir(parents=True, exist_ok=True) + sys_dir = AGENTS_NAS_ROOT / "_system" + sys_dir.mkdir(parents=True, exist_ok=True) + readme = sys_dir / "README.txt" + if not readme.exists(): + readme.write_text( + "Foodlinkk Agents NAS workspace\n" + f"Pad: {AGENTS_NAS_SMB}\n" + "Elke agent schrijft ALLEEN in Agents//...\n" + "Submappen: notes, exports, memory, inbox, outbox\n", + encoding="utf-8", + ) + meta = { + "unc": AGENTS_NAS_UNC, + "smb": AGENTS_NAS_SMB, + "container_path": str(AGENTS_NAS_ROOT), + "updated_at": datetime.now(timezone.utc).isoformat(), + } + (sys_dir / "workspace.json").write_text( + json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8" + ) + return AGENTS_NAS_ROOT + + +def ensure_agent_folder(agent_key: str) -> Path: + ensure_root() + key = slugify(agent_key or "unknown", 32).lower() + base = AGENTS_NAS_ROOT / key + for sub in AGENT_SUBDIRS: + (base / sub).mkdir(parents=True, exist_ok=True) + meta = { + "agent_key": key, + "path": str(base), + "smb": f"{AGENTS_NAS_SMB}/{key}", + "subdirs": list(AGENT_SUBDIRS), + } + (base / "agent.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8") + return base + + +def bootstrap_all(agent_keys: list[str] | None = None) -> dict[str, Any]: + ensure_root() + keys = agent_keys or [] + created = [] + for k in keys: + p = ensure_agent_folder(str(k)) + created.append(str(p)) + return { + "ok": True, + "root": str(AGENTS_NAS_ROOT), + "smb": AGENTS_NAS_SMB, + "agents": created, + "count": len(created), + } + + +def memory_rule_text() -> str: + return ( + "NAS DATA (HARDCODED — VERPLICHT):\n" + "- Alle bestanden/data die jij creëert schrijf je naar de Agents-map op de FL-nas.\n" + f"- Pad: {AGENTS_NAS_SMB}//\n" + f"- Server/container pad: {AGENTS_NAS_ROOT}//\n" + "- Submappen: notes/ (notities), exports/ (rapporten), memory/ (lange-termijn), " + "inbox/ (inkomend), outbox/ (uitgaand).\n" + "- NOOIT alleen in /tmp laten staan — altijd op deze NAS-map opslaan.\n" + "- Noem in je antwoord het NAS-pad als je iets hebt opgeslagen.\n" + ) + + +def write_text(agent_key: str, relative_path: str, content: str, *, subdir: str = "notes") -> dict[str, Any]: + base = ensure_agent_folder(agent_key) + sub = subdir if subdir in AGENT_SUBDIRS else "notes" + rel = relative_path.lstrip("/\\").replace("..", "") + if not rel: + rel = f"note-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.txt" + target = (base / sub / rel).resolve() + if not str(target).startswith(str(base.resolve())): + raise ValueError("Pad buiten agent-map niet toegestaan") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content if content.endswith("\n") else content + "\n", encoding="utf-8") + key = slugify(agent_key).lower() + return { + "ok": True, + "path": str(target), + "smb": f"{AGENTS_NAS_SMB}/{key}/{sub}/{rel}", + } + + +def write_json(agent_key: str, relative_path: str, data: Any, *, subdir: str = "exports") -> dict[str, Any]: + name = relative_path if relative_path.endswith(".json") else relative_path + ".json" + return write_text( + agent_key, + name, + json.dumps(data, indent=2, ensure_ascii=False, default=str), + subdir=subdir, + ) + + +def agent_paths(agent_key: str) -> dict[str, str]: + key = slugify(agent_key or "unknown", 32).lower() + return { + "root": str(AGENTS_NAS_ROOT / key), + "smb": f"{AGENTS_NAS_SMB}/{key}", + } diff --git a/cockpit/app/services/agent_terminal.py b/cockpit/app/services/agent_terminal.py index 76ee0ea..45c004e 100644 --- a/cockpit/app/services/agent_terminal.py +++ b/cockpit/app/services/agent_terminal.py @@ -8,7 +8,7 @@ from typing import Any, Optional import httpx from app.db import fetch_all, fetch_one -from app.services import agent_integration, agent_souls, herman, webbuilder_agent +from app.services import agent_integration, agent_nas, agent_souls, herman, webbuilder_agent from app.services.agent_events_log import log_agent_event from app.services.agent_names import normalize_agent_key @@ -24,6 +24,8 @@ GLOBAL_HELP = [ "handoff [notitie]", "say — log een notitie", "ask — AI-antwoord in rol van deze agent", + "nas — toon Agents NAS-pad", + "save — schrijf naar NAS Agents//notes/", ] AGENT_HELP: dict[str, list[str]] = { @@ -430,6 +432,29 @@ async def execute_terminal_command( else: note = " ".join(args[2:]) if len(args) > 2 else "" lines = _handoff(key, args[0], args[1], note) + elif cmd == "nas": + try: + agent_nas.ensure_agent_folder(key) + paths = agent_nas.agent_paths(key) + lines = [ + _line("Agents NAS: " + str(agent_nas.smb())), + _line("Jouw map: " + str(paths.get("smb") or "")), + _line("Container: " + str(paths.get("root") or "")), + _line("Sub: notes/ exports/ memory/ inbox/ outbox/"), + ] + except Exception as exc: + lines = [_line("NAS fout: " + str(exc), line_type="error")] + elif cmd == "save": + if len(args) < 2: + lines = [_line("Gebruik: save ", line_type="error")] + else: + fname = args[0] + body = " ".join(args[1:]) + try: + result = agent_nas.write_text(key, fname, body, subdir="notes") + lines = [_line("Opgeslagen op NAS: " + str(result.get("smb") or result.get("path")))] + except Exception as exc: + lines = [_line("Save mislukt: " + str(exc), line_type="error")] elif cmd == "say": if not rest: lines = [_line("Gebruik: say ", line_type="error")] diff --git a/cockpit/static/css/agent-characters.css b/cockpit/static/css/agent-characters.css index 8e3d4c0..0737070 100644 --- a/cockpit/static/css/agent-characters.css +++ b/cockpit/static/css/agent-characters.css @@ -138,6 +138,12 @@ inset: 0; background: rgba(0, 0, 0, 0.55); z-index: 1200; + display: none; + pointer-events: none; +} +.agent-events-backdrop.is-open { + display: block; + pointer-events: auto; } .agent-events-drawer { position: fixed; @@ -301,3 +307,18 @@ 0%, 100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); } 50% { box-shadow: 0 0 12px rgba(251,191,36,0.25); } } + +/* Brighter, readable agent avatars on dark UI */ +.agent-char-wrap { + background: linear-gradient(145deg, #1e4a6e, #163a58) !important; + border: 1px solid rgba(56, 189, 248, 0.35) !important; + box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.12), 0 6px 16px rgba(0,0,0,0.25); + overflow: hidden; +} +.agent-char-wrap .agent-char-svg { + filter: brightness(1.18) contrast(1.08) saturate(1.15); +} +.agent-soul-card .agent-char-wrap { + width: 72px; + height: 72px; +} diff --git a/cockpit/static/css/agents-warroom.css b/cockpit/static/css/agents-warroom.css index c40d701..f8a0126 100644 --- a/cockpit/static/css/agents-warroom.css +++ b/cockpit/static/css/agents-warroom.css @@ -279,12 +279,23 @@ background: rgba(255,255,255,0.06); } -/* Call overlay must escape wr-main overflow clipping */ -.wr-main { position: relative; } +.wr-main { position: relative; z-index: 1; } +.wr-composer { + position: relative; + z-index: 6; + pointer-events: auto !important; +} +.wr-composer textarea, +.wr-composer .form-input { + pointer-events: auto !important; + position: relative; + z-index: 7; +} +.wr-log { position: relative; z-index: 1; } .wr-call-overlay { - position: fixed !important; - inset: 0 !important; - z-index: 11000 !important; + display: none !important; + pointer-events: none !important; + visibility: hidden !important; } .btn.is-recording, button.is-recording { @@ -300,14 +311,16 @@ .wr-voice-overlay { position: fixed !important; inset: 0 !important; z-index: 11000 !important; background: rgba(3, 8, 18, 0.88); - display: none; /* CRITICAL: never cover chat when closed */ + display: none !important; align-items: center; justify-content: center; padding: 1rem; - pointer-events: none; + pointer-events: none !important; + visibility: hidden !important; } .wr-voice-overlay.is-open { display: flex !important; - pointer-events: auto; + pointer-events: auto !important; + visibility: visible !important; } .wr-voice-panel { width: min(920px, 96vw); @@ -385,3 +398,267 @@ border-top: 1px solid rgba(255,255,255,0.08); font-size: 0.78rem; } + +/* War Room roster / message avatars — brighter */ +.wr-roster .agent-char-wrap, +.wr-avatar .agent-char-wrap { + background: linear-gradient(145deg, #245a82, #1a4568) !important; + border: 1px solid rgba(56, 189, 248, 0.4) !important; + border-radius: 10px; + overflow: hidden; +} +.wr-roster .agent-char-wrap .agent-char-svg, +.wr-avatar .agent-char-wrap .agent-char-svg { + filter: brightness(1.22) contrast(1.1) saturate(1.2); +} +.wr-chan-icon { + display: inline-flex; + width: 16px; + height: 16px; + color: #38bdf8; + margin-right: 0.35rem; + vertical-align: -2px; +} +.wr-chan-icon svg { width: 16px; height: 16px; display: block; } + +/* Agents vertical tabs — SVG icons */ +.vtabs-nav .vtab-icon-svg { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 8px; + color: #7dd3fc; + background: color-mix(in srgb, #22d3ee 14%, rgba(15,23,42,.6)); + border: 1px solid color-mix(in srgb, #22d3ee 35%, transparent); + font-size: 0; +} +.vtabs-nav .vtab-icon-svg svg { width: 15px; height: 15px; display: block; } +.vtabs-nav .vtab-btn.active .vtab-icon-svg { + color: #fff; + background: color-mix(in srgb, #22d3ee 45%, #0b1220); + border-color: #22d3ee; + box-shadow: 0 0 12px rgba(34, 211, 238, 0.35); +} + +/* War Room richer toolbar / search / dates */ +.wr-head-title { min-width: 0; } +.wr-msg-count { display: block; margin-top: 0.15rem; font-size: 0.7rem; opacity: 0.65; } +.wr-tool-btn.active { + border-color: rgba(34, 211, 238, 0.55) !important; + background: rgba(34, 211, 238, 0.15) !important; +} +.wr-tools-wrap { position: relative; } +.wr-tools-menu { + position: absolute; right: 0; top: calc(100% + 4px); z-index: 40; + min-width: 200px; + background: #0f2438; + border: 1px solid rgba(56, 189, 248, 0.3); + border-radius: 10px; + padding: 0.35rem; + box-shadow: 0 12px 32px rgba(0,0,0,0.45); + display: flex; flex-direction: column; gap: 0.15rem; +} +.wr-tools-menu button { + text-align: left; background: transparent; border: 0; color: #e2e8f0; + padding: 0.45rem 0.55rem; border-radius: 7px; cursor: pointer; font-size: 0.8rem; +} +.wr-tools-menu button:hover { background: rgba(56, 189, 248, 0.12); } +.wr-tools-menu button.wr-danger { color: #fda4af; } +.wr-tools-menu button.wr-danger:hover { background: rgba(244, 63, 94, 0.15); } +.wr-tools-menu hr { + border: 0; border-top: 1px solid rgba(148,163,184,0.2); margin: 0.25rem 0; +} +.wr-searchbar { + display: flex; gap: 0.4rem; align-items: center; + margin-bottom: 0.4rem; flex: 0 0 auto; +} +.wr-searchbar .form-input { flex: 1; min-width: 0; font-size: 0.85rem; padding: 0.4rem 0.65rem; } +.wr-day-sep { + display: flex; align-items: center; justify-content: center; + margin: 0.75rem 0 0.55rem; +} +.wr-day-sep span { + font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; + text-transform: uppercase; + color: #7dd3fc; + background: rgba(14, 165, 233, 0.12); + border: 1px solid rgba(56, 189, 248, 0.25); + padding: 0.2rem 0.7rem; + border-radius: 999px; +} +.wr-bubble-meta { + display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; +} +.wr-who { font-weight: 600; color: #e2e8f0; opacity: 1; } +.wr-handle { opacity: 0.55; font-size: 0.65rem; } +.wr-time { + margin-left: auto; + font-variant-numeric: tabular-nums; + opacity: 0.55; + font-size: 0.65rem; +} +.wr-bubble.is-user .wr-time { margin-left: 0; margin-right: auto; order: -1; } + +.wr-a2a-btn { + font-weight: 700; + letter-spacing: 0.03em; + cursor: pointer; +} +.wr-a2a-btn.is-on { + background: rgba(34, 197, 94, 0.18) !important; + border: 1px solid rgba(34, 197, 94, 0.45) !important; + color: #86efac !important; +} +.wr-a2a-btn.is-off { + background: rgba(148, 163, 184, 0.12) !important; + border: 1px solid rgba(148, 163, 184, 0.35) !important; + color: #94a3b8 !important; +} +.wr-mobile-bar { display: none; } + +/* CEO inbox */ +.wr-ceo-btn { position: relative; } +.wr-ceo-badge { + display: inline-grid; place-items: center; + min-width: 1.1rem; height: 1.1rem; padding: 0 0.25rem; + margin-left: 0.25rem; border-radius: 999px; + background: #f43f5e; color: #fff; font-size: 0.65rem; font-weight: 700; +} +.wr-ceo-overlay { + position: absolute; inset: 0; z-index: 80; + background: rgba(2, 8, 20, 0.72); + display: flex; align-items: stretch; justify-content: flex-end; + border-radius: 10px; +} +.wr-main { position: relative; } +.wr-ceo-panel { + width: min(420px, 100%); + background: linear-gradient(180deg, #0f2744, #0a1c33); + border-left: 1px solid rgba(56, 189, 248, 0.35); + box-shadow: -12px 0 40px rgba(0,0,0,0.45); + display: flex; flex-direction: column; + padding: 0.75rem; +} +.wr-ceo-head { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.65rem; } +.wr-ceo-head h3 { margin: 0; font-size: 0.95rem; } +.wr-ceo-head-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; align-items: flex-start; } +.wr-ceo-list { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 0.55rem; } +.wr-ceo-item { + border: 1px solid rgba(148,163,184,0.18); + background: rgba(15, 23, 42, 0.55); + border-radius: 10px; + padding: 0.55rem 0.65rem; +} +.wr-ceo-item.unread { + border-color: rgba(251, 191, 36, 0.55); + box-shadow: inset 3px 0 0 #fbbf24; + background: rgba(251, 191, 36, 0.08); +} +.wr-ceo-item-meta { + display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; + font-size: 0.72rem; opacity: 0.85; margin-bottom: 0.35rem; +} +.wr-ceo-unread-dot { + background: #fbbf24; color: #111; font-weight: 700; + padding: 0.05rem 0.4rem; border-radius: 999px; font-size: 0.65rem; +} +.wr-ceo-item-body { + white-space: pre-wrap; word-break: break-word; + font-size: 0.86rem; line-height: 1.35; +} +.wr-ceo-item-actions { margin-top: 0.4rem; } +.wr-bubble.is-ceo-direct .wr-bubble-body { + border-color: rgba(251, 191, 36, 0.55) !important; + box-shadow: inset 3px 0 0 #fbbf24; + background: rgba(251, 191, 36, 0.08) !important; +} + + +/* Reply */ +.wr-msg-reply { + margin-left: auto; + border: 0; + background: transparent; + color: inherit; + opacity: 0; + cursor: pointer; + font-size: 0.68rem; + padding: 0 0.2rem; +} +.wr-bubble:hover .wr-msg-reply { opacity: 0.7; } +.wr-msg-reply:hover { opacity: 1 !important; color: var(--accent, #3b82f6); } +.wr-reply-quote { + border-left: 3px solid color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); + padding: 0.2rem 0.45rem; + margin: 0.15rem 0 0.35rem; + font-size: 0.72rem; + opacity: 0.85; + background: color-mix(in srgb, var(--surface-2, #111) 60%, transparent); + border-radius: 0 6px 6px 0; +} +.wr-reply-quote-who { display: block; font-weight: 600; margin-bottom: 0.1rem; } +.wr-reply-quote-text { display: block; opacity: 0.85; white-space: pre-wrap; } +.wr-reply-bar { + grid-column: 1 / -1; + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.4rem 0.55rem; + margin-bottom: 0.25rem; + border-radius: 8px; + border-left: 3px solid var(--accent, #3b82f6); + background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); + font-size: 0.78rem; +} +.wr-reply-bar-text { flex: 1; min-width: 0; } +.wr-reply-bar-text strong { display: block; margin-bottom: 0.1rem; } +.wr-reply-bar-text span { + display: block; + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + + + + +.wr-bubble.is-reply-target .wr-bubble-body { + outline: 2px solid color-mix(in srgb, var(--accent, #38bdf8) 70%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, #38bdf8 18%, transparent); +} +.wr-msg-wrap { scroll-margin: 4rem; } + + +.wr-log-wrap { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} +.wr-log-wrap .wr-log { + flex: 1 1 auto; + min-height: 0; +} +.wr-jump-latest { + position: absolute; + right: 0.75rem; + bottom: 0.75rem; + z-index: 25; + border: 1px solid rgba(56, 189, 248, 0.45); + background: rgba(8, 20, 36, 0.94); + color: #e0f2fe; + border-radius: 999px; + padding: 0.45rem 0.85rem; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + box-shadow: 0 8px 24px rgba(0,0,0,0.35); +} +.wr-jump-latest:hover { + background: rgba(14, 40, 64, 0.98); + border-color: rgba(56, 189, 248, 0.7); +} diff --git a/cockpit/static/css/app-sidebar-neo.css b/cockpit/static/css/app-sidebar-neo.css index 58e4416..1a85743 100644 --- a/cockpit/static/css/app-sidebar-neo.css +++ b/cockpit/static/css/app-sidebar-neo.css @@ -143,24 +143,39 @@ animation: none !important; } + +/* SVG line icons in sidebar */ .sidebar-nav-icon { - width: 28px; - height: 28px; + width: 30px; + height: 30px; flex-shrink: 0; - display: flex; + display: inline-flex; align-items: center; justify-content: center; - border-radius: 6px; - font-size: 0.95rem; - background: rgba(8, 12, 18, 0.9); - border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 8px; + font-size: 0; + color: var(--nav-accent, #7dd3fc); + background: color-mix(in srgb, var(--nav-accent, #22d3ee) 14%, rgba(255,255,255,.06)); + border: 1px solid color-mix(in srgb, var(--nav-accent, #22d3ee) 35%, transparent); + box-shadow: inset 0 1px 0 rgba(255,255,255,.08); +} +.sidebar-nav-icon svg { + width: 16px; + height: 16px; + display: block; +} +.app-sidebar a.nav-pill:hover .sidebar-nav-icon { + background: color-mix(in srgb, var(--nav-accent, #22d3ee) 24%, rgba(255,255,255,.08)); + border-color: color-mix(in srgb, var(--nav-accent, #22d3ee) 55%, transparent); } .app-sidebar a.nav-pill.active .sidebar-nav-icon { - background: rgba(15, 23, 42, 0.95); - border-color: var(--nav-accent); - box-shadow: 0 0 8px rgba(0, 0, 0, 0.3); + color: #fff; + background: color-mix(in srgb, var(--nav-accent, #22d3ee) 55%, #0b1220); + border-color: var(--nav-accent, #22d3ee); + box-shadow: 0 0 12px color-mix(in srgb, var(--nav-accent, #22d3ee) 45%, transparent); } + .sidebar-nav-label { flex: 1; min-width: 0; diff --git a/cockpit/static/css/export-intel.css b/cockpit/static/css/export-intel.css index 98ff50b..24908cd 100644 --- a/cockpit/static/css/export-intel.css +++ b/cockpit/static/css/export-intel.css @@ -1108,3 +1108,11 @@ body.ei-profile-open { overflow: hidden; } .ei-google-hint { font-size: 0.7rem; color: #64748b; margin: 0 0 0.45rem; } .ei-google-key-row { display: flex; gap: 0.4rem; margin-bottom: 0.45rem; } .ei-google-key-row .form-input { flex: 1; min-width: 0; font-size: 0.75rem; padding: 0.35rem 0.5rem; } + +.ei-progress-actions { display:flex; gap:0.5rem; justify-content:center; margin-top:0.85rem; flex-wrap:wrap; } +.ei-btn-cancel { + background: rgba(239,68,68,.18) !important; + border: 1px solid rgba(239,68,68,.55) !important; + color: #fecaca !important; +} +.ei-btn-cancel:hover { background: rgba(239,68,68,.32) !important; } diff --git a/cockpit/static/css/flk-themes.css b/cockpit/static/css/flk-themes.css index 1b30dd9..15ba1f8 100644 --- a/cockpit/static/css/flk-themes.css +++ b/cockpit/static/css/flk-themes.css @@ -1,164 +1,318 @@ -/* Foodlinkk UI themes — colorful cyan/blue family, mid-dark, vivid accents */ -/* Applied via (localStorage key: flk-theme) */ +/* Foodlinkk UI + Sidebar themes + * UI: localStorage: flk-theme + * Sidebar: localStorage: flk-sidebar-theme + */ +/* ========== UI THEMES (1–5 existing) ========== */ html[data-theme="ocean"] { - --bg-root: #07131f; - --bg-surface: #0c1c2e; - --bg-elevated: #123049; - --bg-hover: #183a58; - --border-subtle: #1f4b6e; - --border-strong: #2d6a96; - --text-primary: #eef9ff; - --text-secondary: #b6e3f7; - --text-muted: #7eb6d4; - --text-body: #d5effc; - --accent-cyan: #22d3ee; - --accent-cyan-dim: rgba(34, 211, 238, 0.22); - --accent-purple: #67e8f9; - --accent-purple-dim: rgba(103, 232, 249, 0.18); - --accent-amber: #38bdf8; - --accent-amber-dim: rgba(56, 189, 248, 0.2); - --accent-green: #2dd4bf; - --accent-green-dim: rgba(45, 212, 191, 0.2); - --accent-red: #fb7185; - --accent-red-dim: rgba(251, 113, 133, 0.18); - --shadow-panel: 0 8px 32px rgba(8, 47, 73, 0.55); - --flk-theme-glow: rgba(34, 211, 238, 0.35); + --bg-root: #07131f; --bg-surface: #0c1c2e; --bg-elevated: #123049; --bg-hover: #183a58; + --border-subtle: #1f4b6e; --border-strong: #2d6a96; + --text-primary: #eef9ff; --text-secondary: #b6e3f7; --text-muted: #7eb6d4; --text-body: #d5effc; + --accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22); + --accent-purple: #67e8f9; --accent-purple-dim: rgba(103,232,249,.18); + --accent-amber: #38bdf8; --accent-amber-dim: rgba(56,189,248,.2); + --accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2); + --accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18); + --shadow-panel: 0 8px 32px rgba(8,47,73,.55); --flk-theme-glow: rgba(34,211,238,.35); } - html[data-theme="aurora"] { - --bg-root: #0a1020; - --bg-surface: #121a32; - --bg-elevated: #1a2550; - --bg-hover: #243266; - --border-subtle: #3347a0; - --border-strong: #4f6dff; - --text-primary: #f0f4ff; - --text-secondary: #c7d2fe; - --text-muted: #9db0f5; - --text-body: #dde4ff; - --accent-cyan: #22d3ee; - --accent-cyan-dim: rgba(34, 211, 238, 0.2); - --accent-purple: #a78bfa; - --accent-purple-dim: rgba(167, 139, 250, 0.22); - --accent-amber: #818cf8; - --accent-amber-dim: rgba(129, 140, 248, 0.22); - --accent-green: #34d399; - --accent-green-dim: rgba(52, 211, 153, 0.2); - --accent-red: #f472b6; - --accent-red-dim: rgba(244, 114, 182, 0.2); - --shadow-panel: 0 8px 32px rgba(49, 46, 129, 0.5); - --flk-theme-glow: rgba(167, 139, 250, 0.4); + --bg-root: #0a1020; --bg-surface: #121a32; --bg-elevated: #1a2550; --bg-hover: #243266; + --border-subtle: #3347a0; --border-strong: #4f6dff; + --text-primary: #f0f4ff; --text-secondary: #c7d2fe; --text-muted: #9db0f5; --text-body: #dde4ff; + --accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2); + --accent-purple: #a78bfa; --accent-purple-dim: rgba(167,139,250,.22); + --accent-amber: #818cf8; --accent-amber-dim: rgba(129,140,248,.22); + --accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2); + --accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.2); + --shadow-panel: 0 8px 32px rgba(49,46,129,.5); --flk-theme-glow: rgba(167,139,250,.4); } - html[data-theme="neon"] { - --bg-root: #050b14; - --bg-surface: #0a1628; - --bg-elevated: #10243f; - --bg-hover: #163255; - --border-subtle: #1a4d6e; - --border-strong: #06b6d4; - --text-primary: #ecfeff; - --text-secondary: #a5f3fc; - --text-muted: #67e8f9; - --text-body: #cffafe; - --accent-cyan: #06b6d4; - --accent-cyan-dim: rgba(6, 182, 212, 0.28); - --accent-purple: #e879f9; - --accent-purple-dim: rgba(232, 121, 249, 0.2); - --accent-amber: #22d3ee; - --accent-amber-dim: rgba(34, 211, 238, 0.25); - --accent-green: #4ade80; - --accent-green-dim: rgba(74, 222, 128, 0.22); - --accent-red: #ff6bcb; - --accent-red-dim: rgba(255, 107, 203, 0.22); - --shadow-panel: 0 0 40px rgba(6, 182, 212, 0.18); - --flk-theme-glow: rgba(6, 182, 212, 0.45); + --bg-root: #050b14; --bg-surface: #0a1628; --bg-elevated: #10243f; --bg-hover: #163255; + --border-subtle: #1a4d6e; --border-strong: #06b6d4; + --text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe; + --accent-cyan: #06b6d4; --accent-cyan-dim: rgba(6,182,212,.28); + --accent-purple: #e879f9; --accent-purple-dim: rgba(232,121,249,.2); + --accent-amber: #22d3ee; --accent-amber-dim: rgba(34,211,238,.25); + --accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22); + --accent-red: #ff6bcb; --accent-red-dim: rgba(255,107,203,.22); + --shadow-panel: 0 0 40px rgba(6,182,212,.18); --flk-theme-glow: rgba(6,182,212,.45); } - html[data-theme="glacier"] { - --bg-root: #0b1522; - --bg-surface: #122033; - --bg-elevated: #1a2f4a; - --bg-hover: #223b5a; - --border-subtle: #2a5078; - --border-strong: #3b82f6; - --text-primary: #f0f9ff; - --text-secondary: #bae6fd; - --text-muted: #7dd3fc; - --text-body: #e0f2fe; - --accent-cyan: #38bdf8; - --accent-cyan-dim: rgba(56, 189, 248, 0.22); - --accent-purple: #60a5fa; - --accent-purple-dim: rgba(96, 165, 250, 0.2); - --accent-amber: #7dd3fc; - --accent-amber-dim: rgba(125, 211, 252, 0.2); - --accent-green: #2dd4bf; - --accent-green-dim: rgba(45, 212, 191, 0.2); - --accent-red: #fb7185; - --accent-red-dim: rgba(251, 113, 133, 0.18); - --shadow-panel: 0 8px 28px rgba(30, 64, 175, 0.35); - --flk-theme-glow: rgba(56, 189, 248, 0.35); + --bg-root: #0b1522; --bg-surface: #122033; --bg-elevated: #1a2f4a; --bg-hover: #223b5a; + --border-subtle: #2a5078; --border-strong: #3b82f6; + --text-primary: #f0f9ff; --text-secondary: #bae6fd; --text-muted: #7dd3fc; --text-body: #e0f2fe; + --accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.22); + --accent-purple: #60a5fa; --accent-purple-dim: rgba(96,165,250,.2); + --accent-amber: #7dd3fc; --accent-amber-dim: rgba(125,211,252,.2); + --accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2); + --accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18); + --shadow-panel: 0 8px 28px rgba(30,64,175,.35); --flk-theme-glow: rgba(56,189,248,.35); } - html[data-theme="cobalt"] { - --bg-root: #08101c; - --bg-surface: #0f1a2e; - --bg-elevated: #172642; - --bg-hover: #1f3256; - --border-subtle: #274878; - --border-strong: #3b82f6; - --text-primary: #eff6ff; - --text-secondary: #bfdbfe; - --text-muted: #93c5fd; - --text-body: #dbeafe; - --accent-cyan: #22d3ee; - --accent-cyan-dim: rgba(34, 211, 238, 0.2); - --accent-purple: #818cf8; - --accent-purple-dim: rgba(129, 140, 248, 0.22); - --accent-amber: #fbbf24; - --accent-amber-dim: rgba(251, 191, 36, 0.18); - --accent-green: #34d399; - --accent-green-dim: rgba(52, 211, 153, 0.2); - --accent-red: #f87171; - --accent-red-dim: rgba(248, 113, 113, 0.18); - --shadow-panel: 0 10px 36px rgba(37, 99, 235, 0.28); - --flk-theme-glow: rgba(59, 130, 246, 0.4); + --bg-root: #08101c; --bg-surface: #0f1a2e; --bg-elevated: #172642; --bg-hover: #1f3256; + --border-subtle: #274878; --border-strong: #3b82f6; + --text-primary: #eff6ff; --text-secondary: #bfdbfe; --text-muted: #93c5fd; --text-body: #dbeafe; + --accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2); + --accent-purple: #818cf8; --accent-purple-dim: rgba(129,140,248,.22); + --accent-amber: #fbbf24; --accent-amber-dim: rgba(251,191,36,.18); + --accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2); + --accent-red: #f87171; --accent-red-dim: rgba(248,113,113,.18); + --shadow-panel: 0 10px 36px rgba(37,99,235,.28); --flk-theme-glow: rgba(59,130,246,.4); +} + +/* ========== UI THEMES (6–15 new) ========== */ +html[data-theme="reef"] { + --bg-root: #04161a; --bg-surface: #0a242b; --bg-elevated: #11353f; --bg-hover: #174650; + --border-subtle: #1f6b78; --border-strong: #2dd4bf; + --text-primary: #ecfeff; --text-secondary: #99f6e4; --text-muted: #5eead4; --text-body: #ccfbf1; + --accent-cyan: #2dd4bf; --accent-cyan-dim: rgba(45,212,191,.25); + --accent-purple: #22d3ee; --accent-purple-dim: rgba(34,211,238,.2); + --accent-amber: #fbbf24; --accent-amber-dim: rgba(251,191,36,.2); + --accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22); + --accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.2); + --shadow-panel: 0 10px 36px rgba(13,148,136,.35); --flk-theme-glow: rgba(45,212,191,.45); +} +html[data-theme="plasma"] { + --bg-root: #0c0618; --bg-surface: #180b2e; --bg-elevated: #26114a; --bg-hover: #321860; + --border-subtle: #6d28d9; --border-strong: #a78bfa; + --text-primary: #faf5ff; --text-secondary: #e9d5ff; --text-muted: #c4b5fd; --text-body: #f3e8ff; + --accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22); + --accent-purple: #c084fc; --accent-purple-dim: rgba(192,132,252,.25); + --accent-amber: #f0abfc; --accent-amber-dim: rgba(240,171,252,.2); + --accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2); + --accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.22); + --shadow-panel: 0 10px 40px rgba(109,40,217,.4); --flk-theme-glow: rgba(192,132,252,.5); +} +html[data-theme="lagoon"] { + --bg-root: #031a1f; --bg-surface: #072830; --bg-elevated: #0c3a45; --bg-hover: #114c59; + --border-subtle: #0e7490; --border-strong: #22d3ee; + --text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe; + --accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.28); + --accent-purple: #2dd4bf; --accent-purple-dim: rgba(45,212,191,.22); + --accent-amber: #38bdf8; --accent-amber-dim: rgba(56,189,248,.22); + --accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22); + --accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18); + --shadow-panel: 0 10px 36px rgba(8,145,178,.35); --flk-theme-glow: rgba(34,211,238,.45); +} +html[data-theme="indigo"] { + --bg-root: #07091a; --bg-surface: #0f1430; --bg-elevated: #1a2250; --bg-hover: #242d68; + --border-subtle: #3730a3; --border-strong: #6366f1; + --text-primary: #eef2ff; --text-secondary: #c7d2fe; --text-muted: #a5b4fc; --text-body: #e0e7ff; + --accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2); + --accent-purple: #818cf8; --accent-purple-dim: rgba(129,140,248,.25); + --accent-amber: #60a5fa; --accent-amber-dim: rgba(96,165,250,.22); + --accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2); + --accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.2); + --shadow-panel: 0 10px 36px rgba(67,56,202,.4); --flk-theme-glow: rgba(99,102,241,.45); +} +html[data-theme="skyline"] { + --bg-root: #06101c; --bg-surface: #0c1c33; --bg-elevated: #142d4d; --bg-hover: #1b3c64; + --border-subtle: #0284c7; --border-strong: #38bdf8; + --text-primary: #f0f9ff; --text-secondary: #bae6fd; --text-muted: #7dd3fc; --text-body: #e0f2fe; + --accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.28); + --accent-purple: #0ea5e9; --accent-purple-dim: rgba(14,165,233,.22); + --accent-amber: #7dd3fc; --accent-amber-dim: rgba(125,211,252,.22); + --accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2); + --accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18); + --shadow-panel: 0 10px 36px rgba(2,132,199,.35); --flk-theme-glow: rgba(56,189,248,.5); +} +html[data-theme="mintvolt"] { + --bg-root: #04140f; --bg-surface: #0a221a; --bg-elevated: #10352a; --bg-hover: #164738; + --border-subtle: #059669; --border-strong: #34d399; + --text-primary: #ecfdf5; --text-secondary: #a7f3d0; --text-muted: #6ee7b7; --text-body: #d1fae5; + --accent-cyan: #2dd4bf; --accent-cyan-dim: rgba(45,212,191,.25); + --accent-purple: #22d3ee; --accent-purple-dim: rgba(34,211,238,.2); + --accent-amber: #a3e635; --accent-amber-dim: rgba(163,230,53,.22); + --accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.25); + --accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18); + --shadow-panel: 0 10px 36px rgba(5,150,105,.35); --flk-theme-glow: rgba(52,211,153,.45); +} +html[data-theme="sapphire"] { + --bg-root: #050d1f; --bg-surface: #0b1835; --bg-elevated: #122650; --bg-hover: #1a3470; + --border-subtle: #1d4ed8; --border-strong: #3b82f6; + --text-primary: #eff6ff; --text-secondary: #bfdbfe; --text-muted: #93c5fd; --text-body: #dbeafe; + --accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.25); + --accent-purple: #60a5fa; --accent-purple-dim: rgba(96,165,250,.22); + --accent-amber: #22d3ee; --accent-amber-dim: rgba(34,211,238,.2); + --accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2); + --accent-red: #f87171; --accent-red-dim: rgba(248,113,113,.18); + --shadow-panel: 0 10px 40px rgba(29,78,216,.4); --flk-theme-glow: rgba(59,130,246,.5); +} +html[data-theme="arctic"] { + --bg-root: #0a121c; --bg-surface: #121c2a; --bg-elevated: #1c2a3d; --bg-hover: #26384f; + --border-subtle: #64748b; --border-strong: #94a3b8; + --text-primary: #f8fafc; --text-secondary: #e2e8f0; --text-muted: #94a3b8; --text-body: #f1f5f9; + --accent-cyan: #67e8f9; --accent-cyan-dim: rgba(103,232,249,.25); + --accent-purple: #7dd3fc; --accent-purple-dim: rgba(125,211,252,.22); + --accent-amber: #bae6fd; --accent-amber-dim: rgba(186,230,253,.2); + --accent-green: #5eead4; --accent-green-dim: rgba(94,234,212,.2); + --accent-red: #fda4af; --accent-red-dim: rgba(253,164,175,.2); + --shadow-panel: 0 10px 32px rgba(100,116,139,.3); --flk-theme-glow: rgba(125,211,252,.4); +} +html[data-theme="hyperion"] { + --bg-root: #041018; --bg-surface: #081c2a; --bg-elevated: #0e2d42; --bg-hover: #143d58; + --border-subtle: #0891b2; --border-strong: #22d3ee; + --text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe; + --accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.3); + --accent-purple: #a3e635; --accent-purple-dim: rgba(163,230,53,.22); + --accent-amber: #4ade80; --accent-amber-dim: rgba(74,222,128,.22); + --accent-green: #84cc16; --accent-green-dim: rgba(132,204,22,.22); + --accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18); + --shadow-panel: 0 0 42px rgba(8,145,178,.3); --flk-theme-glow: rgba(34,211,238,.55); +} +html[data-theme="meridian"] { + --bg-root: #0a0716; --bg-surface: #140d28; --bg-elevated: #221645; --bg-hover: #2e1e5c; + --border-subtle: #7c3aed; --border-strong: #a78bfa; + --text-primary: #f5f3ff; --text-secondary: #ddd6fe; --text-muted: #c4b5fd; --text-body: #ede9fe; + --accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22); + --accent-purple: #c084fc; --accent-purple-dim: rgba(192,132,252,.28); + --accent-amber: #e879f9; --accent-amber-dim: rgba(232,121,249,.22); + --accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2); + --accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.22); + --shadow-panel: 0 10px 40px rgba(124,58,237,.4); --flk-theme-glow: rgba(167,139,250,.5); } -/* Soft global accents when a theme is active */ html[data-theme] body { background: radial-gradient(1200px 600px at 10% -10%, color-mix(in srgb, var(--accent-cyan) 18%, transparent), transparent 60%), radial-gradient(900px 500px at 100% 0%, color-mix(in srgb, var(--accent-purple) 14%, transparent), transparent 55%), var(--bg-root); } -html[data-theme] .sidebar, -html[data-theme] .app-sidebar { - border-right-color: color-mix(in srgb, var(--accent-cyan) 35%, var(--border-subtle)); - box-shadow: inset -1px 0 0 color-mix(in srgb, var(--accent-cyan) 15%, transparent); +html[data-theme] .panel, +html[data-theme] .hm-neo { + border-color: color-mix(in srgb, var(--accent-cyan) 22%, var(--border-subtle)); } html[data-theme] .btn-pulse, html[data-theme] .btn-primary { box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-cyan) 40%, transparent), 0 8px 24px color-mix(in srgb, var(--accent-cyan) 22%, transparent); } -html[data-theme] .panel, -html[data-theme] .hm-neo { - border-color: color-mix(in srgb, var(--accent-cyan) 22%, var(--border-subtle)); + +/* ========== SIDEBAR THEMES (independent, bright) ========== */ +html[data-sidebar] .app-sidebar { + border-right: 1px solid color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 45%, transparent) !important; + box-shadow: inset -1px 0 0 color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 20%, transparent), + 4px 0 28px color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 12%, transparent); +} +html[data-sidebar] .app-sidebar a.nav-pill:hover { + background: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 16%, transparent) !important; + border-color: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 35%, transparent); +} +html[data-sidebar] .app-sidebar a.nav-pill.active { + background: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 22%, rgba(8,12,18,.9)) !important; + box-shadow: inset 3px 0 0 var(--flk-sidebar-accent, #22d3ee); +} +html[data-sidebar] .app-sidebar-label { + color: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 85%, #fff) !important; } -/* Settings theme cards */ + + +html[data-sidebar="aqua"] .app-sidebar { + --flk-sidebar-accent: #5eead4; + --flk-sidebar-solid: #0a4f5c; + --flk-sidebar-grad: linear-gradient(180deg, #062a32 0%, #0a4f5c 55%, #0d6b7a 100%); + background: var(--flk-sidebar-grad) !important; +} +html[data-sidebar="azure"] .app-sidebar { + --flk-sidebar-accent: #60a5fa; + --flk-sidebar-solid: #163a7a; + --flk-sidebar-grad: linear-gradient(180deg, #0a1f45 0%, #163a7a 55%, #1e4f9e 100%); + background: var(--flk-sidebar-grad) !important; +} +html[data-sidebar="turquoise"] .app-sidebar { + --flk-sidebar-accent: #2dd4bf; + --flk-sidebar-solid: #0a453f; + --flk-sidebar-grad: linear-gradient(180deg, #052925 0%, #0a453f 55%, #0f5f56 100%); + background: var(--flk-sidebar-grad) !important; +} +html[data-sidebar="cerulean"] .app-sidebar { + --flk-sidebar-accent: #38bdf8; + --flk-sidebar-solid: #0c4a6e; + --flk-sidebar-grad: linear-gradient(180deg, #06263a 0%, #0c4a6e 55%, #126087 100%); + background: var(--flk-sidebar-grad) !important; +} +html[data-sidebar="electric"] .app-sidebar { + --flk-sidebar-accent: #22d3ee; + --flk-sidebar-solid: #0a3d4a; + --flk-sidebar-grad: linear-gradient(180deg, #041820 0%, #0a3d4a 50%, #0e5a6b 100%); + background: var(--flk-sidebar-grad) !important; +} +html[data-sidebar="violet"] .app-sidebar { + --flk-sidebar-accent: #818cf8; + --flk-sidebar-solid: #2a1f6b; + --flk-sidebar-grad: linear-gradient(180deg, #150e3a 0%, #2a1f6b 55%, #3b2f8a 100%); + background: var(--flk-sidebar-grad) !important; +} +html[data-sidebar="emerald"] .app-sidebar { + --flk-sidebar-accent: #34d399; + --flk-sidebar-solid: #0a4a38; + --flk-sidebar-grad: linear-gradient(180deg, #05291e 0%, #0a4a38 55%, #0f6349 100%); + background: var(--flk-sidebar-grad) !important; +} +html[data-sidebar="midnight"] .app-sidebar { + --flk-sidebar-accent: #7dd3fc; + --flk-sidebar-solid: #0d1f3c; + --flk-sidebar-grad: linear-gradient(180deg, #060e1c 0%, #0d1f3c 55%, #153056 100%); + background: var(--flk-sidebar-grad) !important; +} +html[data-sidebar="steel"] .app-sidebar { + --flk-sidebar-accent: #94a3b8; + --flk-sidebar-solid: #1a2d42; + --flk-sidebar-grad: linear-gradient(180deg, #0d1622 0%, #1a2d42 55%, #243b55 100%); + background: var(--flk-sidebar-grad) !important; +} +html[data-sidebar="nordic"] .app-sidebar { + --flk-sidebar-accent: #67e8f9; + --flk-sidebar-solid: #0e3a4f; + --flk-sidebar-grad: linear-gradient(180deg, #071f2c 0%, #0e3a4f 55%, #15506a 100%); + background: var(--flk-sidebar-grad) !important; +} + +/* Sidebar fill mode: gradient (default) vs plain solid */ +html[data-sidebar][data-sidebar-style="plain"] .app-sidebar { + background: var(--flk-sidebar-solid, #0a3d4a) !important; +} +html[data-sidebar]:not([data-sidebar-style="plain"]) .app-sidebar { + background: var(--flk-sidebar-grad) !important; +} + +.flk-style-toggle { + display: inline-flex; + gap: 0.35rem; + margin: 0.65rem 0 0.85rem; + padding: 0.25rem; + border-radius: 10px; + background: color-mix(in srgb, var(--bg-elevated, #123049) 70%, transparent); + border: 1px solid color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent); +} +.flk-style-toggle button { + border: 0; + background: transparent; + color: var(--text-secondary, #b6e3f7); + padding: 0.4rem 0.85rem; + border-radius: 8px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; +} +.flk-style-toggle button.is-active { + background: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent); + color: #fff; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-cyan, #22d3ee) 45%, transparent); +} + +/* Settings cards */ .flk-theme-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 0.85rem; - margin-top: 1rem; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 0.75rem; + margin-top: 0.85rem; } .flk-theme-card { border: 1px solid var(--border-subtle); border-radius: 14px; - padding: 0.85rem; + padding: 0.75rem; background: var(--bg-elevated); cursor: pointer; text-align: left; @@ -171,10 +325,267 @@ html[data-theme] .hm-neo { box-shadow: 0 0 0 2px var(--accent-cyan-dim), 0 12px 28px rgba(0,0,0,.35); } .flk-theme-swatch { - height: 52px; - border-radius: 10px; - margin-bottom: 0.65rem; - border: 1px solid rgba(255,255,255,0.12); + height: 52px; border-radius: 10px; margin-bottom: 0.55rem; + border: 1px solid rgba(255,255,255,.18); + background-color: #1e293b; + background-size: cover !important; + background-repeat: no-repeat !important; +} +.flk-theme-card strong { display: block; margin-bottom: 0.12rem; font-size: 0.9rem; } +.flk-theme-card small { opacity: 0.75; line-height: 1.3; display: block; font-size: 0.75rem; } +.flk-theme-section { margin-top: 1.75rem; } +.flk-theme-section h3 { margin: 0 0 0.25rem; font-size: 1.05rem; } + +/* ========== PAGE BACKGROUNDS (independent of UI theme) ========== + * localStorage: flk-page-bg + * Applies to body + app-main + common subpage shells + */ +html[data-bg="navy"] { --flk-page-bg: #0d2b5c; --flk-page-bg2: #134078; --bg-root: #0d2b5c; } +html[data-bg="royal"] { --flk-page-bg: #1a3a8f; --flk-page-bg2: #274bb8; --bg-root: #1a3a8f; } +html[data-bg="azure"] { --flk-page-bg: #0a4f8a; --flk-page-bg2: #0d6cb8; --bg-root: #0a4f8a; } +html[data-bg="sky"] { --flk-page-bg: #0e5f9e; --flk-page-bg2: #1880c9; --bg-root: #0e5f9e; } +html[data-bg="cerulean"] { --flk-page-bg: #0c6a9e; --flk-page-bg2: #1490c8; --bg-root: #0c6a9e; } +html[data-bg="teal"] { --flk-page-bg: #0c5c68; --flk-page-bg2: #128090; --bg-root: #0c5c68; } +html[data-bg="lagoon"] { --flk-page-bg: #0a6b7a; --flk-page-bg2: #0f8fa3; --bg-root: #0a6b7a; } +html[data-bg="cyan"] { --flk-page-bg: #0a5568; --flk-page-bg2: #0e7a94; --bg-root: #0a5568; } +html[data-bg="indigo"] { --flk-page-bg: #2f2a7a; --flk-page-bg2: #4338ca; --bg-root: #2f2a7a; } +html[data-bg="violet"] { --flk-page-bg: #4a1d7a; --flk-page-bg2: #6d28d9; --bg-root: #4a1d7a; } +html[data-bg="sapphire"] { --flk-page-bg: #123a8c; --flk-page-bg2: #1d4ed8; --bg-root: #123a8c; } +html[data-bg="midnight"] { --flk-page-bg: #1a2f55; --flk-page-bg2: #274878; --bg-root: #1a2f55; } +html[data-bg="emerald"] { --flk-page-bg: #0d5c45; --flk-page-bg2: #059669; --bg-root: #0d5c45; } +html[data-bg="slate"] { --flk-page-bg: #243447; --flk-page-bg2: #334155; --bg-root: #243447; } +html[data-bg="graphite"] { --flk-page-bg: #1e2a3a; --flk-page-bg2: #2c3b52; --bg-root: #1e2a3a; } + +html[data-bg] body { + background: + radial-gradient(ellipse 90% 55% at 12% -8%, color-mix(in srgb, var(--accent-cyan, #22d3ee) 18%, transparent), transparent 55%), + radial-gradient(ellipse 70% 45% at 92% 8%, color-mix(in srgb, var(--accent-purple, #818cf8) 14%, transparent), transparent 50%), + linear-gradient(165deg, var(--flk-page-bg) 0%, var(--flk-page-bg2, var(--flk-page-bg)) 100%) !important; + background-attachment: fixed !important; +} +html[data-bg] .app-main, +html[data-bg] .app-shell, +html[data-bg] main, +html[data-bg] .page, +html[data-bg] .page-wrap, +html[data-bg] .content-area, +html[data-bg] .settings-layout, +html[data-bg] .hm-page, +html[data-bg] .warroom-root, +html[data-bg] .agents-page { + background: transparent !important; +} +html[data-bg] .panel, +html[data-bg] .hm-neo, +html[data-bg] .card, +html[data-bg] .settings-panel { + background: color-mix(in srgb, var(--bg-elevated, #123049) 82%, transparent) !important; + backdrop-filter: blur(8px); +} + +/* Kill leftover near-black shells when page bg is set */ +html[data-bg] .app-main > *, +html[data-bg] .dashboard-root, +html[data-bg] .retail-page, +html[data-bg] .ops-page, +html[data-bg] .documents-page, +html[data-bg] .analytics-wrap, +html[data-bg] .export-intel, +html[data-bg] .clients-page, +html[data-bg] .wr-shell, +html[data-bg] .voice-page, +html[data-bg] .studio-page, +html[data-bg] .hermes-page, +html[data-bg] .projects-hub, +html[data-bg] .packaging-page, +html[data-bg] .beurs-page, +html[data-bg] .marketing-page, +html[data-bg] .revenue-cockpit { + background-color: transparent !important; +} +html[data-bg] { + background: var(--flk-page-bg) !important; +} + +/* ========== SURFACE OVERRIDE (kill leftover blacks on subpages) ========== */ +html[data-theme], +html[data-bg] { + --flk-surface: color-mix(in srgb, var(--bg-elevated, #123049) 88%, #fff 0%); + --flk-surface-2: color-mix(in srgb, var(--bg-surface, #0c1c2e) 92%, var(--accent-cyan, #22d3ee) 8%); + --flk-input: color-mix(in srgb, var(--bg-root, #07131f) 70%, var(--bg-elevated, #123049) 30%); + --flk-panel: color-mix(in srgb, var(--bg-elevated, #123049) 75%, transparent); +} + +/* When a page background is chosen, lift surface tokens off near-black */ +html[data-bg="navy"] { --bg-surface: #134078; --bg-elevated: #1a4f96; --bg-hover: #2460b0; } +html[data-bg="royal"] { --bg-surface: #274bb8; --bg-elevated: #3359d4; --bg-hover: #4169e8; } +html[data-bg="azure"] { --bg-surface: #0d6cb8; --bg-elevated: #1580d4; --bg-hover: #1e92ea; } +html[data-bg="sky"] { --bg-surface: #1880c9; --bg-elevated: #2493de; --bg-hover: #36a4ec; } +html[data-bg="cerulean"] { --bg-surface: #1490c8; --bg-elevated: #20a3dc; --bg-hover: #32b4ea; } +html[data-bg="teal"] { --bg-surface: #128090; --bg-elevated: #1898a8; --bg-hover: #22aebb; } +html[data-bg="lagoon"] { --bg-surface: #0f8fa3; --bg-elevated: #18a4ba; --bg-hover: #24b8ce; } +html[data-bg="cyan"] { --bg-surface: #0e7a94; --bg-elevated: #1490ae; --bg-hover: #1ea4c4; } +html[data-bg="indigo"] { --bg-surface: #4338ca; --bg-elevated: #5450e0; --bg-hover: #6666f0; } +html[data-bg="violet"] { --bg-surface: #6d28d9; --bg-elevated: #7e3aec; --bg-hover: #8f4cf8; } +html[data-bg="sapphire"] { --bg-surface: #1d4ed8; --bg-elevated: #2b5fec; --bg-hover: #3b70f8; } +html[data-bg="midnight"] { --bg-surface: #274878; --bg-elevated: #335c96; --bg-hover: #4070b0; } +html[data-bg="emerald"] { --bg-surface: #059669; --bg-elevated: #0aad78; --bg-hover: #14c08a; } +html[data-bg="slate"] { --bg-surface: #334155; --bg-elevated: #3f5068; --bg-hover: #4d6078; } +html[data-bg="graphite"] { --bg-surface: #2c3b52; --bg-elevated: #384a66; --bg-hover: #465a78; } + +/* App chrome */ +html[data-theme] .app-topbar, +html[data-bg] .app-topbar, +html[data-theme] .app-main, +html[data-bg] .app-main { + background: transparent !important; +} +html[data-theme] .app-topbar, +html[data-bg] .app-topbar { + background: color-mix(in srgb, var(--bg-elevated, #123049) 55%, transparent) !important; + border-bottom-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent) !important; + backdrop-filter: blur(10px); +} + +/* Generic panels / cards */ +html[data-theme] .panel, +html[data-bg] .panel, +html[data-theme] .card, +html[data-bg] .card, +html[data-theme] .hm-neo, +html[data-bg] .hm-neo, +html[data-theme] .settings-panel, +html[data-bg] .settings-panel { + background: color-mix(in srgb, var(--bg-elevated, #123049) 78%, transparent) !important; + border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, var(--border-subtle, #1f4b6e)) !important; +} + +/* ========== Export Intel surfaces ========== */ +html[data-theme] .ei-page, +html[data-bg] .ei-page { + background: transparent !important; +} +html[data-theme] .ei-kpi, +html[data-bg] .ei-kpi, +html[data-theme] .ei-neo-kpi, +html[data-bg] .ei-neo-kpi { + background: color-mix(in srgb, var(--bg-elevated, #123049) 82%, transparent) !important; + border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important; +} +html[data-theme] .ei-neo-ring-inner, +html[data-bg] .ei-neo-ring-inner { + background: var(--bg-surface, #0c1c2e) !important; +} +html[data-theme] .ei-filter-bar, +html[data-bg] .ei-filter-bar { + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--accent-cyan, #22d3ee) 16%, var(--bg-elevated, #123049)), + color-mix(in srgb, var(--bg-surface, #0c1c2e) 90%, transparent) + ) !important; + border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important; +} +html[data-theme] .ei-filters select, +html[data-theme] .ei-filters input, +html[data-theme] .ei-filter-label select, +html[data-theme] .ei-filter-label input, +html[data-bg] .ei-filters select, +html[data-bg] .ei-filters input, +html[data-bg] .ei-filter-label select, +html[data-bg] .ei-filter-label input { + background: color-mix(in srgb, var(--bg-root, #07131f) 55%, var(--bg-elevated, #123049) 45%) !important; + border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important; + color: var(--text-primary, #eef9ff) !important; +} +html[data-theme] .ei-tab-panel, +html[data-bg] .ei-tab-panel, +html[data-theme] .ei-tab-panel:has(.ei-workspace-body), +html[data-bg] .ei-tab-panel:has(.ei-workspace-body) { + background: color-mix(in srgb, var(--bg-elevated, #123049) 72%, transparent) !important; + border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, transparent) !important; +} +html[data-theme] .ei-side-panel, +html[data-bg] .ei-side-panel { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-elevated, #123049) 92%, #fff 0%), + color-mix(in srgb, var(--bg-surface, #0c1c2e) 95%, transparent) + ) !important; + border-left-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent) !important; +} +html[data-theme] .ei-side-panel-head, +html[data-bg] .ei-side-panel-head { + background: color-mix(in srgb, var(--bg-hover, #183a58) 55%, transparent) !important; +} +html[data-theme] .ei-map-wrap, +html[data-bg] .ei-map-wrap { + background: color-mix(in srgb, var(--bg-root, #07131f) 80%, var(--bg-elevated, #123049) 20%) !important; + border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important; +} +html[data-theme] .ei-map-loading, +html[data-bg] .ei-map-loading { + background: color-mix(in srgb, var(--bg-root, #07131f) 70%, transparent) !important; +} +html[data-theme] .ei-map-wrap .leaflet-popup-content-wrapper, +html[data-theme] .ei-map-wrap .leaflet-popup-tip, +html[data-bg] .ei-map-wrap .leaflet-popup-content-wrapper, +html[data-bg] .ei-map-wrap .leaflet-popup-tip { + background: var(--bg-elevated, #123049) !important; + border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 40%, transparent) !important; +} +html[data-theme] .ei-table th, +html[data-bg] .ei-table th { + background: var(--bg-elevated, #123049) !important; +} +html[data-theme] .ei-table tbody tr, +html[data-bg] .ei-table tbody tr { + background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 55%, transparent); +} +html[data-theme] .ei-table tbody tr:nth-child(even), +html[data-bg] .ei-table tbody tr:nth-child(even) { + background: color-mix(in srgb, var(--bg-elevated, #123049) 45%, transparent); +} +html[data-theme] .ei-drawer, +html[data-bg] .ei-drawer, +html[data-theme] .ei-modal-card, +html[data-bg] .ei-modal-card, +html[data-theme] .ei-progress-card, +html[data-bg] .ei-progress-card { + background: var(--bg-elevated, #123049) !important; + border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important; +} +html[data-theme] .ei-sync-monitor, +html[data-bg] .ei-sync-monitor { + background: color-mix(in srgb, var(--bg-elevated, #123049) 88%, transparent) !important; + border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important; +} +html[data-theme] .ei-sync-job, +html[data-bg] .ei-sync-job, +html[data-theme] .ei-sync-coverage div, +html[data-bg] .ei-sync-coverage div, +html[data-theme] .ei-google-places, +html[data-bg] .ei-google-places { + background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 70%, transparent) !important; +} +html[data-theme] .wereldexport-workspace > .vtabs-nav, +html[data-bg] .wereldexport-workspace > .vtabs-nav { + background: color-mix(in srgb, var(--bg-elevated, #123049) 70%, transparent) !important; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, transparent); +} +html[data-theme] .vtab-btn, +html[data-bg] .vtab-btn { + background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 55%, transparent) !important; +} +html[data-theme] .vtab-btn.active, +html[data-bg] .vtab-btn.active { + background: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, var(--bg-elevated, #123049)) !important; +} + +/* Persona / brand switch in topbar */ +html[data-theme] .persona-btn, +html[data-bg] .persona-btn, +html[data-theme] .brand-switch .persona-btn, +html[data-bg] .brand-switch .persona-btn { + background: color-mix(in srgb, var(--bg-elevated, #123049) 60%, transparent) !important; } -.flk-theme-card strong { display: block; margin-bottom: 0.15rem; } -.flk-theme-card small { opacity: 0.75; line-height: 1.35; display: block; } diff --git a/cockpit/static/css/mobile.css b/cockpit/static/css/mobile.css index 1768ffb..5961eae 100644 --- a/cockpit/static/css/mobile.css +++ b/cockpit/static/css/mobile.css @@ -191,3 +191,256 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); } @media (display-mode: standalone), (display-mode: fullscreen) { .pwa-install-bar { display: none !important; } } + + + + + +/* ===== War Room FULL CHAT on phone (Android + iOS) ===== */ +@media (max-width: 900px) { + /* Tabs: compact chip row */ + .vtabs-layout { + display: flex !important; + flex-direction: column !important; + gap: 0.3rem !important; + min-height: 0 !important; + } + .vtabs-nav { + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + overflow-x: auto !important; + -webkit-overflow-scrolling: touch !important; + width: 100% !important; + max-width: 100% !important; + position: sticky !important; + top: 0 !important; + z-index: 50 !important; + padding: 0.3rem !important; + margin: 0 !important; + } + .vtabs-nav .vtab-btn { + flex: 0 0 auto !important; + width: auto !important; + white-space: nowrap !important; + min-height: 40px !important; + padding: 0.35rem 0.6rem !important; + } + .vtabs-nav-header { display: none !important; } + .vtabs-content { width: 100% !important; min-width: 0 !important; flex: 1 1 auto !important; min-height: 0 !important; } + + /* Hide bulky page chrome in War Room */ + .hm-neo.tab-warroom-active > .wr-page-header, + .hm-neo.tab-warroom-active > .page-header { + display: none !important; + } + .hm-neo.tab-warroom-active { + display: flex !important; + flex-direction: column !important; + height: calc(100dvh - 3.25rem - env(safe-area-inset-top, 0px)) !important; + max-height: calc(100dvh - 3.25rem - env(safe-area-inset-top, 0px)) !important; + min-height: 0 !important; + margin: 0 !important; + padding: 0 !important; + } + .hm-neo.tab-warroom-active .vtabs-layout { + flex: 1 1 auto !important; + min-height: 0 !important; + height: 100% !important; + } + .hm-neo.tab-warroom-active .vtabs-content { + display: flex !important; + flex-direction: column !important; + flex: 1 1 auto !important; + min-height: 0 !important; + height: 100% !important; + } + /* Let Alpine x-show hide inactive tabs; only stretch the visible warroom section */ + .hm-neo.tab-warroom-active .vtabs-content > section:not([style*="display: none"]) { + display: flex !important; + flex-direction: column !important; + flex: 1 1 auto !important; + min-height: 0 !important; + height: 100% !important; + } + + .wr-hub-root { + display: flex !important; + flex-direction: column !important; + flex: 1 1 auto !important; + min-height: 0 !important; + height: 100% !important; + } + .wr-hub-root > .panel { + display: flex !important; + flex-direction: column !important; + flex: 1 1 auto !important; + min-height: 0 !important; + height: 100% !important; + max-height: none !important; + padding: 0.35rem !important; + margin: 0 !important; + border-radius: 10px !important; + } + .wr-layout { + display: flex !important; + flex-direction: column !important; + flex: 1 1 auto !important; + height: 100% !important; + max-height: none !important; + min-height: 0 !important; + gap: 0.3rem !important; + } + + .wr-mobile-bar { + display: flex !important; + flex: 0 0 auto; + gap: 0.3rem; + align-items: center; + justify-content: space-between; + } + .wr-layout.roster-collapsed .wr-roster { display: none !important; } + .wr-roster { + flex: 0 0 auto !important; + max-height: 18vh !important; + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + overflow-x: auto !important; + -webkit-overflow-scrolling: touch; + border: 0 !important; + border-bottom: 1px solid rgba(148,163,184,0.15) !important; + padding: 0 0 0.25rem !important; + } + .wr-roster h3 { display: none !important; } + .wr-room-btn, .wr-agent-btn { + flex: 0 0 auto !important; + width: auto !important; + min-width: 6rem; + } + + .wr-main { + display: flex !important; + flex-direction: column !important; + flex: 1 1 auto !important; + min-height: 0 !important; + height: auto !important; + max-height: none !important; + } + + /* Compact toolbar */ + .wr-head { + flex: 0 0 auto !important; + margin: 0 !important; + gap: 0.25rem !important; + } + .wr-head-title h2 { font-size: 0.9rem !important; } + .wr-head-title .muted, + .wr-head-title .wr-msg-count { display: none !important; } + .wr-head-actions { + width: 100%; + gap: 0.25rem !important; + flex-wrap: nowrap !important; + overflow-x: auto !important; + -webkit-overflow-scrolling: touch; + } + .wr-head-actions .btn { + flex: 0 0 auto; + min-height: 36px !important; + padding: 0.3rem 0.45rem !important; + font-size: 0.75rem !important; + } + .wr-head-actions .wr-a2a-btn, + .wr-head-actions button[title="Open op tweede scherm"], + .wr-head-actions button[title="Spraakmeeting met agents"], + .wr-head-actions button[title="Snel praten"] { + display: none !important; + } + + /* THE chat window — take all remaining space */ + .wr-log { + flex: 1 1 auto !important; + min-height: 50dvh !important; + height: auto !important; + max-height: none !important; + overflow-y: auto !important; + overflow-x: hidden !important; + -webkit-overflow-scrolling: touch !important; + overscroll-behavior: contain; + padding: 0.5rem !important; + } + + /* Readable bubbles */ + .wr-bubble { gap: 0.4rem !important; margin-bottom: 0.55rem !important; } + .wr-avatar { width: 34px !important; height: 34px !important; flex-basis: 34px !important; } + .wr-avatar img, .wr-avatar .agent-char-wrap, + .wr-avatar .agent-char-wrap svg, + .wr-avatar-fallback { width: 34px !important; height: 34px !important; } + .wr-bubble-body { + max-width: calc(100% - 42px) !important; + width: auto !important; + padding: 0.45rem 0.55rem !important; + } + .wr-bubble-text { + font-size: 1rem !important; + line-height: 1.45 !important; + white-space: pre-wrap !important; + word-break: break-word !important; + overflow-wrap: anywhere !important; + } + .wr-bubble-meta { font-size: 0.72rem !important; } + .wr-msg-reply, .wr-msg-del { opacity: 0.8 !important; } + + .wr-composer { + flex: 0 0 auto !important; + position: relative !important; + bottom: auto !important; + z-index: 20; + margin-top: 0.25rem !important; + padding-bottom: max(0.35rem, env(safe-area-inset-bottom, 0px)) !important; + background: rgba(8, 12, 18, 0.98); + } + .wr-composer textarea, + .wr-composer .form-input { + font-size: 16px !important; + min-height: 48px !important; + line-height: 1.35 !important; + } + .wr-composer .btn-pulse-green { + min-height: 48px !important; + min-width: 4.5rem !important; + } + .wr-ceo-panel { + width: 100% !important; + max-height: 85dvh !important; + } +} + +/* Less top padding around agents content on phone */ +@media (max-width: 900px) { + .app-main .main-topnav { + padding-top: 0.45rem !important; + padding-bottom: 0.45rem !important; + } + body:has(.tab-warroom-active) .app-main .main-topnav .page-tip { display: none !important; } +} + +@supports not (height: 100dvh) { + @media (max-width: 900px) { + .hm-neo.tab-warroom-active { + height: calc(100vh - 3.25rem) !important; + max-height: calc(100vh - 3.25rem) !important; + } + .wr-log { min-height: 50vh !important; } + } +} + + +/* reply-target mobile */ +@media (max-width: 900px) { + .wr-bubble.is-reply-target .wr-bubble-body { + outline: 2px solid #38bdf8; + } + .wr-msg-del { opacity: 0.35 !important; } + .wr-msg-reply { opacity: 1 !important; font-weight: 600; } +} diff --git a/cockpit/static/css/vertical-tabs.css b/cockpit/static/css/vertical-tabs.css index 358d389..b7be706 100644 --- a/cockpit/static/css/vertical-tabs.css +++ b/cockpit/static/css/vertical-tabs.css @@ -276,3 +276,55 @@ .vtab-btn.vtab-btn-rich .ei-vtab-text, .vtab-btn.vtab-btn-rich .ei-vtab-key { display: none; } } + + +/* MOBILE TABS FIX — horizontal chip bar (Android + iOS) */ +@media (max-width: 900px) { + .vtabs-layout { + flex-direction: column !important; + align-items: stretch !important; + gap: 0.5rem !important; + min-height: 0 !important; + } + .vtabs-layout > .vtabs-nav { + flex: 0 0 auto !important; + width: 100% !important; + max-width: none !important; + } + .vtabs-layout > .vtabs-content { + flex: 1 1 auto !important; + min-width: 0 !important; + width: 100% !important; + } + .vtabs-nav { + position: sticky !important; + top: 0 !important; + z-index: 40 !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + align-items: center !important; + gap: 0.3rem !important; + padding: 0.4rem !important; + overflow-x: auto !important; + overflow-y: hidden !important; + -webkit-overflow-scrolling: touch !important; + scrollbar-width: none; + } + .vtabs-nav::-webkit-scrollbar { display: none; } + .vtabs-nav::before { display: none !important; } + .vtabs-nav-header { display: none !important; } + .vtab-btn { + flex: 0 0 auto !important; + width: auto !important; + max-width: none !important; + min-width: auto !important; + white-space: nowrap !important; + padding: 0.45rem 0.7rem !important; + font-size: 0.82rem !important; + } + .vtab-btn .vtab-icon, + .vtab-btn .vtab-icon-svg { + width: 26px !important; + height: 26px !important; + } +} diff --git a/cockpit/static/js/agent-avatars.js b/cockpit/static/js/agent-avatars.js index 8c50ba7..c33dc06 100644 --- a/cockpit/static/js/agent-avatars.js +++ b/cockpit/static/js/agent-avatars.js @@ -1,38 +1,48 @@ -/** 3D-style agent poppetjes met headset — consistent team look */ +/** Agent avatars — bright cool cyan/blue team look (readable on dark UI) */ window.AgentAvatars = (function () { var CFG = { - herman: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'exec', accent: '#ffd700' }, - marketing: { hoodie: '#9d174d', hair: '#1f2937', skin: '#f5c9a8', style: 'bob', accent: '#ff6b9d' }, - bizdev: { hoodie: '#0e4d6e', hair: '#2d1810', skin: '#ddb896', style: 'short', accent: '#00e5ff' }, - finance: { hoodie: '#14532d', hair: '#3d2314', skin: '#e8b896', style: 'glasses', accent: '#22c55e' }, - sourcing: { hoodie: '#9a3412', hair: '#1a1a1a', skin: '#c9a07a', style: 'short', accent: '#f97316' }, - product: { hoodie: '#581c87', hair: '#4a3728', skin: '#f5c9a8', style: 'curl', accent: '#a855f7' }, - halal: { hoodie: '#065f46', hair: '#1f2937', skin: '#d4a574', style: 'short', accent: '#34d399' }, - design: { hoodie: '#831843', hair: '#111827', skin: '#f5c9a8', style: 'long', accent: '#ec4899' }, - knowledge: { hoodie: '#312e81', hair: '#374151', skin: '#e8b896', style: 'glasses', accent: '#6366f1' }, - retail: { hoodie: '#365314', hair: '#2d1810', skin: '#ddb896', style: 'short', accent: '#b8ff3c' }, - browser: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#c9a07a', style: 'short', accent: '#38bdf8' }, - hr: { hoodie: '#6b21a8', hair: '#2d1810', skin: '#ddb896', style: 'bob', accent: '#e879f9' }, - email: { hoodie: '#334155', hair: '#4a3728', skin: '#ddb896', style: 'short', accent: '#94a3b8' }, - research: { hoodie: '#115e59', hair: '#1a1a1a', skin: '#f5c9a8', style: 'glasses', accent: '#14b8a6' }, - sysops: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'short', accent: '#38bdf8', hat: 'cap' }, - packaging: { hoodie: '#7c2d12', hair: '#2d1810', skin: '#c9a07a', style: 'short', accent: '#fb923c' }, - webbuilder: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#ddb896', style: 'glasses', accent: '#0ea5e9', hat: 'cap' }, + herman: { hoodie: '#1d4ed8', hair: '#5b3a29', skin: '#f0c9a8', style: 'exec', accent: '#fbbf24' }, + marketing: { hoodie: '#0891b2', hair: '#1f2937', skin: '#f5d0b0', style: 'bob', accent: '#22d3ee' }, + bizdev: { hoodie: '#0284c7', hair: '#3d2314', skin: '#e8c4a0', style: 'short', accent: '#38bdf8' }, + finance: { hoodie: '#0d9488', hair: '#3d2314', skin: '#f0c9a8', style: 'glasses', accent: '#2dd4bf' }, + sourcing: { hoodie: '#0369a1', hair: '#1a1a1a', skin: '#e0b890', style: 'short', accent: '#7dd3fc' }, + product: { hoodie: '#4f46e5', hair: '#5b3a29', skin: '#f5d0b0', style: 'curl', accent: '#818cf8' }, + halal: { hoodie: '#059669', hair: '#1f2937', skin: '#e8c4a0', style: 'short', accent: '#34d399' }, + design: { hoodie: '#0e7490', hair: '#111827', skin: '#f5d0b0', style: 'long', accent: '#67e8f9' }, + knowledge: { hoodie: '#4338ca', hair: '#374151', skin: '#f0c9a8', style: 'glasses', accent: '#a5b4fc' }, + retail: { hoodie: '#0f766e', hair: '#3d2314', skin: '#e8c4a0', style: 'short', accent: '#5eead4' }, + browser: { hoodie: '#2563eb', hair: '#1f2937', skin: '#e0b890', style: 'short', accent: '#60a5fa' }, + hr: { hoodie: '#6366f1', hair: '#3d2314', skin: '#e8c4a0', style: 'bob', accent: '#c7d2fe' }, + email: { hoodie: '#475569', hair: '#5b3a29', skin: '#e8c4a0', style: 'short', accent: '#94a3b8' }, + research: { hoodie: '#0f766e', hair: '#1a1a1a', skin: '#f5d0b0', style: 'glasses', accent: '#2dd4bf' }, + sysops: { hoodie: '#1e40af', hair: '#5b3a29', skin: '#f0c9a8', style: 'short', accent: '#38bdf8', hat: 'cap' }, + packaging: { hoodie: '#1d4ed8', hair: '#3d2314', skin: '#e0b890', style: 'short', accent: '#93c5fd' }, + webbuilder: { hoodie: '#0284c7', hair: '#1f2937', skin: '#e8c4a0', style: 'glasses', accent: '#38bdf8', hat: 'cap' }, }; function cfg(key, fallbackColor) { var k = (key || 'agent').toLowerCase(); - var c = CFG[k] || { hoodie: fallbackColor || '#1e3a5f', hair: '#3d2314', skin: '#e8b896', style: 'short', accent: '#38bdf8' }; + var c = CFG[k] || { hoodie: fallbackColor || '#0284c7', hair: '#3d2314', skin: '#f0c9a8', style: 'short', accent: '#38bdf8' }; return c; } + function lighten(hex, amt) { + hex = (hex || '#0284c7').replace('#', ''); + if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; + var n = parseInt(hex, 16); + var r = Math.min(255, ((n >> 16) & 255) + amt); + var g = Math.min(255, ((n >> 8) & 255) + amt); + var b = Math.min(255, (n & 255) + amt); + return '#' + (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1); + } + function capSvg(hid, c) { return ( - '' + - '' + - '' + - '' + - '' + '' + + '' + + '' + + '' + + '' ); } @@ -45,50 +55,51 @@ window.AgentAvatars = (function () { function svgAvatar(key, color) { var c = cfg(key, color); - var hid = 'av-' + key; + var hid = 'av-' + key + '-' + Math.random().toString(36).slice(2, 7); + var hoodLite = lighten(c.hoodie, 55); return ( '' ); @@ -98,7 +109,7 @@ window.AgentAvatars = (function () { if (!el) return; var k = (key || '').toLowerCase(); var c = color || ''; - var sig = k + '|' + c; + var sig = k + '|v2|' + c; if (!force && el.getAttribute('data-avatar-painted') === sig && el.querySelector('svg')) return; el.innerHTML = svgAvatar(k, color); el.setAttribute('data-avatar-painted', sig); diff --git a/cockpit/static/js/agents-warroom.js b/cockpit/static/js/agents-warroom.js index 0be8ffb..4a0a4c2 100644 --- a/cockpit/static/js/agents-warroom.js +++ b/cockpit/static/js/agents-warroom.js @@ -17,6 +17,21 @@ window.WarRoomHub = (function () { rooms: [], room: null, messages: [], + chatSearch: '', + searchOpen: false, + toolsOpen: false, + screenCleared: false, + autoDebate: true, + ceoInboxOpen: false, + ceoInbox: [], + ceoUnread: 0, + replyTo: null, + replyHighlightId: null, + stickToBottom: true, + showJumpLatest: false, + _typingScrollAt: 0, + rosterCollapsed: true, + a2aPausedUntil: 0, draft: '', busy: false, me: { username: '', avatar_url: '', role: '' }, @@ -37,9 +52,9 @@ window.WarRoomHub = (function () { callSlug: '', voiceRecording: false, voiceBusy: false, - speakReplies: true, + speakReplies: false, voiceRoomOpen: false, - voiceRoomAgents: ['herman'], + voiceRoomAgents: ['herman', 'bizdev', 'finance', 'retail', 'marketing'], voiceSpeakingKey: '', voiceLastHeard: '', _voiceRec: null, @@ -49,6 +64,7 @@ window.WarRoomHub = (function () { async init() { this.voiceRoomOpen = false; this.busy = false; + this._sendLock = false; await Promise.all([this.loadMe(), this.loadSouls(), this.loadRooms()]); var params = new URLSearchParams(window.location.search || ''); var want = params.get('room'); @@ -58,7 +74,13 @@ window.WarRoomHub = (function () { if (wr) await this.openRoom(wr.id); else if (this.rooms.length) await this.openRoom(this.rooms[0].id); } + try { + var saved = localStorage.getItem('flk-warroom-a2a'); + this.autoDebate = (saved === null || saved === '1' || saved === 'true'); + } catch (e) { this.autoDebate = true; } this.startPoll(); + this.loadCeoInbox(); + if (this.autoDebate && this.room && this.room.kind === 'warroom') this.scheduleContinue(60000); }, destroy() { @@ -129,26 +151,35 @@ window.WarRoomHub = (function () { async openRoom(id) { if (!id) return; + id = parseInt(id, 10); this.busy = true; this.status = ''; this.typing = null; try { + if (!(this.rooms || []).length) await this.loadRooms(); + var meta = (this.rooms || []).find(function (r) { return Number(r.id) === id; }) || null; var r = await fetch('/api/agents/warroom/rooms/' + id + '/messages?limit=120').then(function (x) { return x.json(); }); - this.room = r.room; + // messages API returns {items}, NOT {room} — keep room from rooms list + this.room = meta || r.room || { id: id, kind: 'warroom', title: 'War Room' }; this.messages = this.normalizeList(r.items || []); this.lastId = this.maxId(this.messages); + this.stickToBottom = true; + this.showJumpLatest = false; this.$nextTick(function () { this.paintAvatars(); - this.scrollLog(); + this.scrollLog(true); + this.focusComposer(); }.bind(this)); var last = (this.messages || [])[(this.messages || []).length - 1]; if (last && last.sender_type === 'agent' && this.room && this.room.kind === 'warroom') { - this.scheduleContinue(4000); + if (this.autoDebate) this.scheduleContinue(60000); } } catch (e) { this.status = e.message || 'Laden mislukt'; + // Still keep a usable room id so send works + if (!this.room) this.room = { id: id, kind: 'warroom', title: 'War Room' }; } this.busy = false; }, @@ -203,12 +234,44 @@ window.WarRoomHub = (function () { return changed; }, - scrollLog() { + isNearBottom(el, px) { + if (!el) return true; + var gap = el.scrollHeight - el.scrollTop - el.clientHeight; + return gap <= (px == null ? 80 : px); + }, + + onLogScroll() { var el = this.$refs && this.$refs.wrLog; - if (el) el.scrollTop = el.scrollHeight; + if (!el) return; + var near = this.isNearBottom(el, 100); + this.stickToBottom = near; + this.showJumpLatest = !near; + }, + + scrollLog(force) { + var el = this.$refs && this.$refs.wrLog; + if (!el) return; + if (!force && !this.stickToBottom) { + this.showJumpLatest = true; + return; + } + el.scrollTop = el.scrollHeight; + this.stickToBottom = true; + this.showJumpLatest = false; + }, + + jumpToLatest() { + this.stickToBottom = true; + this.showJumpLatest = false; + this.scrollLog(true); }, startPoll() { + var selfCeo = this; + if (!this._ceoPoll) { + this._ceoPoll = setInterval(function () { selfCeo.loadCeoInbox(); }, 20000); + } + var self = this; if (this._poll) clearInterval(this._poll); this._poll = setInterval(function () { self.pollNew(); }, 4000); @@ -283,6 +346,7 @@ window.WarRoomHub = (function () { }, onDraftInput() { + if ((this.draft || '').trim()) this.pauseA2A(90000); var d = this.draft || ''; var m = /(^|\s)@([a-zA-Z0-9_-]*)$/.exec(d); if (m) { @@ -344,51 +408,153 @@ window.WarRoomHub = (function () { this.pendingFiles = (this.pendingFiles || []).filter(function (_, i) { return i !== idx; }); }, + focusComposer() { + try { + var el = this.$refs && this.$refs.wrInput; + if (el) { + el.removeAttribute('disabled'); + el.readOnly = false; + el.focus(); + } + } catch (e) {} + }, + canSend() { - return !this.busy && this.room && (((this.draft || '').trim()) || (this.pendingFiles || []).length); + var hasText = !!(this.draft || '').trim(); + var hasFiles = !!(this.pendingFiles || []).length; + return !!(hasText || hasFiles); + }, + + async ensureRoom() { + if (this.room && this.room.id) return true; + try { + await this.loadRooms(); + var wr = this.warroomRoom(); + if (wr) { + await this.openRoom(wr.id); + } else { + // fallback: known warroom id 1 + await this.openRoom(1); + } + } catch (e) {} + return !!(this.room && this.room.id); + }, + + onEnter(e) { + if (e && e.shiftKey) return; // Shift+Enter = nieuwe regel + if (this.mentionOpen) { + var sug = this.mentionSuggestions(); + if (sug && sug.length) { + if (e) e.preventDefault(); + this.pickMention(sug[this.mentionIndex] || sug[0]); + return; + } + } + if (e) e.preventDefault(); + this.send(); }, async send() { var text = (this.draft || '').trim(); var files = (this.pendingFiles || []).slice(); - if (!this.room || this.busy) return; if (!text && !files.length) return; + // Never get stuck behind a stale busy flag + if (this._sendLock) return; + this._sendLock = true; this.busy = true; + this.mentionOpen = false; this.emojiOpen = false; + + var okRoom = await this.ensureRoom(); + if (!okRoom) { + this.busy = false; + this._sendLock = false; + this.status = 'Room niet geladen — klik ↻'; + if (window.Cockpit) Cockpit.toast(this.status, 'error'); + return; + } + + // Instant UI — no waiting for network/LLM + var tempId = 'local-' + Date.now(); + var replySnapshot = this.replyTo ? { + id: this.replyTo.id, + agent_key: this.replyTo.agent_key, + username: this.replyTo.username, + sender_type: this.replyTo.sender_type, + excerpt: this.replyTo.excerpt, + label: this.replyTo.label, + } : null; + var replyToId = replySnapshot ? replySnapshot.id : null; + var optimisticMeta = {}; + if (files.length) optimisticMeta.attachments = files; + if (replySnapshot) optimisticMeta.reply_to = replySnapshot; + var optimistic = { + id: tempId, + room_id: this.room.id, + sender_type: 'user', + content: text || (files.length ? '(bijlage)' : ''), + username: (this.me && this.me.username) || 'Jij', + sender_avatar_url: (this.me && this.me.avatar_url) || null, + metadata: optimisticMeta, + created_at: new Date().toISOString(), + }; this.draft = ''; this.pendingFiles = []; + this.replyTo = null; + this.replyHighlightId = replyToId; + this.pauseA2A(90000); this.typing = null; - this.status = 'Verzenden…'; + this.upsertMessage(optimistic); + this.status = ''; + this.stickToBottom = true; + this.$nextTick(function () { + this.scrollLog(true); + this.focusComposer(); + }.bind(this)); try { var resp = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/messages/stream', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, - body: JSON.stringify({ content: text, attachments: files }), + body: JSON.stringify({ content: text, attachments: files, reply_to_id: replyToId }), }); if (!resp.ok) { var err = await resp.json().catch(function () { return {}; }); throw new Error(err.detail || ('HTTP ' + resp.status)); } await this.consumeStream(resp); - this.status = ''; - this.scheduleContinue(2000); + // Drop temp bubble if server user msg arrived with real id + this.messages = (this.messages || []).filter(function (m) { + return m.id !== tempId; + }); + // Herlaad recente geschiedenis (merge) zodat reply-parent niet "kwijt" raakt in de UI + await this.refreshMessages({ silent: true }); + if (replyToId) { + var self = this; + this.$nextTick(function () { + self.scrollToMessage(replyToId); + setTimeout(function () { self.replyHighlightId = null; }, 4000); + }); + } + if (this.autoDebate) this.scheduleContinue(60000); } catch (e) { - this.draft = text; - this.pendingFiles = files; + // Keep optimistic message; restore draft only on hard fail this.status = e.message || 'Verzenden mislukt'; if (window.Cockpit) Cockpit.toast(this.status, 'error'); } this.typing = null; this.busy = false; + this._sendLock = false; this.$nextTick(function () { this.paintAvatars(); - this.scrollLog(); + this.scrollLog(true); + this.focusComposer(); }.bind(this)); }, + async consumeStream(resp) { var reader = resp.body.getReader(); var decoder = new TextDecoder(); @@ -422,6 +588,7 @@ window.WarRoomHub = (function () { } var type = ev.event || ''; if (type === 'user' && ev.message) { + this.messages = (this.messages || []).filter(function (m) { return String(m.id).indexOf('local-') !== 0; }); this.upsertMessage(ev.message); this.status = 'Agent typt…'; this.$nextTick(function () { this.scrollLog(); }.bind(this)); @@ -434,7 +601,9 @@ window.WarRoomHub = (function () { text: '', }; this.status = (this.typing.display_name || 'Agent') + ' typt…'; - this.$nextTick(function () { this.scrollLog(); }.bind(this)); + // alleen meescrollen als je al onderaan zit + if (this.stickToBottom) this.$nextTick(function () { this.scrollLog(); }.bind(this)); + else this.showJumpLatest = true; } else if (type === 'typing') { if (!this.typing || this.typing.agent_key !== ev.agent_key) { this.typing = { @@ -446,11 +615,20 @@ window.WarRoomHub = (function () { this.typing.text = (this.typing.text || '') + (ev.delta || ''); // force alpine refresh this.typing = Object.assign({}, this.typing); - this.$nextTick(function () { this.scrollLog(); }.bind(this)); + // geen jump tijdens teruglezen; max 1x / 600ms als je onderaan zit + if (this.stickToBottom) { + var now = Date.now(); + if (now - (this._typingScrollAt || 0) > 600) { + this._typingScrollAt = now; + this.$nextTick(function () { this.scrollLog(); }.bind(this)); + } + } else { + this.showJumpLatest = true; + } } else if (type === 'agent' && ev.message) { this.typing = null; this.upsertMessage(ev.message); - if (ev.message.content) this.speakText(ev.message.content, ev.message.agent_key); + if (this.voiceRoomOpen && this.speakReplies && ev.message.content) this.speakText(ev.message.content, ev.message.agent_key); this.$nextTick(function () { this.paintAvatars(); this.scrollLog(); @@ -468,54 +646,142 @@ window.WarRoomHub = (function () { }, onKey(e) { + if (!this.mentionOpen) return; var sug = this.mentionSuggestions(); - if (this.mentionOpen && sug.length) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - this.mentionIndex = (this.mentionIndex + 1) % sug.length; - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - this.mentionIndex = (this.mentionIndex - 1 + sug.length) % sug.length; - return; - } - if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault(); - this.pickMention(sug[this.mentionIndex] || sug[0]); - return; - } - if (e.key === 'Escape') { - this.mentionOpen = false; - return; - } - } - if (e.key === 'Enter' && !e.shiftKey) { + if (!sug.length) return; + if (e.key === 'ArrowDown') { e.preventDefault(); - this.send(); + this.mentionIndex = (this.mentionIndex + 1) % sug.length; + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + this.mentionIndex = (this.mentionIndex - 1 + sug.length) % sug.length; + return; + } + if (e.key === 'Tab') { + e.preventDefault(); + this.pickMention(sug[this.mentionIndex] || sug[0]); + return; + } + if (e.key === 'Escape') { + this.mentionOpen = false; } }, scheduleContinue(delayMs) { - if (!this.autoDebate || !this.room || this.room.kind !== 'warroom') return; if (this._continueTimer) clearTimeout(this._continueTimer); + if (!this.autoDebate) return; var self = this; - this._continueTimer = setTimeout(function () { self.runContinue(2); }, delayMs || 2500); + // default: rustig tempo (~50s), niet elke paar seconden + var wait = (typeof delayMs === 'number' && delayMs >= 0) ? delayMs : 50000; + this._continueTimer = setTimeout(function () { self.runContinue(1); }, wait); + }, + + toggleA2A() { + this.autoDebate = !this.autoDebate; + try { localStorage.setItem('flk-warroom-a2a', this.autoDebate ? '1' : '0'); } catch (e) {} + if (this._continueTimer) clearTimeout(this._continueTimer); + if (this.autoDebate) { + this.status = 'A2A aan — agents mogen onderling verder praten'; + this.scheduleContinue(8000); + if (window.Cockpit) Cockpit.toast('A2A aan', 'ok'); + } else { + this.status = 'A2A uit — alleen reacties op jouw berichten'; + this.a2aPausedUntil = 0; + if (window.Cockpit) Cockpit.toast('A2A uit', 'ok'); + } + }, + + pauseA2A(ms) { + this.a2aPausedUntil = Date.now() + (ms || 45000); + if (this._continueTimer) clearTimeout(this._continueTimer); + }, + + setReply(m) { + if (!m || !m.id || String(m.id).indexOf('local-') === 0) return; + this.replyTo = { + id: m.id, + agent_key: m.agent_key || null, + username: m.username || null, + sender_type: m.sender_type, + excerpt: ((m.content || '').trim().slice(0, 160)) + (((m.content || '').length > 160) ? '…' : ''), + label: this.labelForMessage(m), + }; + this.replyHighlightId = m.id; + this.pauseA2A(90000); + this.$nextTick(function () { + this.scrollToMessage(m.id); + this.focusComposer(); + }.bind(this)); + }, + + scrollToMessage(id) { + if (id == null) return; + var root = (this.$refs && this.$refs.wrLog) || document; + var el = root.querySelector && root.querySelector('[data-msg-id="' + id + '"]'); + if (el && el.scrollIntoView) { + try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) { + el.scrollIntoView(true); + } + } + }, + + async refreshMessages(opts) { + opts = opts || {}; + if (!this.room || !this.room.id) return; + try { + var r = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/messages?limit=200', { + credentials: 'same-origin', + }).then(function (x) { return x.json(); }); + var items = this.normalizeList(r.items || []); + if (items.length) { + this.messages = items; + this.lastId = this.maxId(items); + } + if (!opts.silent) { + this.$nextTick(function () { + this.paintAvatars(); + this.scrollLog(); + }.bind(this)); + } else { + this.$nextTick(function () { this.paintAvatars(); }.bind(this)); + } + } catch (e) { /* quiet */ } + }, + + clearReply() { + this.replyTo = null; + this.replyHighlightId = null; + }, + + replyPreview(m) { + var meta = (m && m.metadata) || {}; + return meta.reply_to || null; }, async runContinue(turns) { if (!this.autoDebate || !this.room || this.room.kind !== 'warroom') return; - if (this.busy || this._continueBusy) return; - // only continue if last message is from an agent (they are mid-debate) - var last = (this.messages || [])[(this.messages || []).length - 1]; - if (!last || last.sender_type !== 'agent') return; + if (this.busy || this._continueBusy || this._sendLock) { + this.scheduleContinue(20000); + return; + } + if (Date.now() < (this.a2aPausedUntil || 0)) { + this.scheduleContinue(Math.max(10000, this.a2aPausedUntil - Date.now())); + return; + } + // niet doorkletsen terwijl jij typt + if ((this.draft || '').trim().length > 0 || this.replyTo) { + this.scheduleContinue(25000); + return; + } this._continueBusy = true; - this.status = 'Agents overleggen verder…'; + this.status = 'A2A — agents reageren…'; try { - var resp = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/continue?turns=' + (turns || 2), { + var resp = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/continue?turns=' + (turns || 1), { method: 'POST', credentials: 'same-origin', headers: { Accept: 'text/event-stream', 'Content-Type': 'application/json' }, @@ -525,16 +791,208 @@ window.WarRoomHub = (function () { this.busy = true; await this.consumeStream(resp); this.busy = false; - this.status = ''; - // keep the debate alive a bit while the tab is open - this.scheduleContinue(12000); + this.status = 'A2A live'; + // keep conversation alive + this.scheduleContinue(55000); } catch (e) { this.busy = false; - this.status = ''; + this.status = 'A2A hervatten…'; + this.scheduleContinue(45000); } this._continueBusy = false; }, + + + async loadCeoInbox() { + try { + var r = await fetch('/api/agents/warroom/ceo-inbox?limit=40', { credentials: 'same-origin' }) + .then(function (x) { return x.json(); }); + this.ceoInbox = r.items || []; + this.ceoUnread = r.unread || 0; + } catch (e) { + this.ceoInbox = this.ceoInbox || []; + } + }, + + openCeoInbox() { + this.ceoInboxOpen = true; + this.loadCeoInbox(); + }, + + closeCeoInbox() { + this.ceoInboxOpen = false; + }, + + async markCeoRead(id) { + try { + await fetch('/api/agents/warroom/ceo-inbox/read', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(id ? { ids: [id] } : { all: true }), + }); + await this.loadCeoInbox(); + } catch (e) {} + }, + + async markAllCeoRead() { + await this.markCeoRead(null); + if (window.Cockpit) Cockpit.toast('CEO-inbox gelezen', 'ok'); + }, + + isCeoDirect(m) { + if (!m) return false; + if (m.ceo_inbox_id || (m.metadata && m.metadata.ceo_direct)) return true; + var c = (m.content || '').toLowerCase(); + return c.indexOf('@aissa') >= 0 || c.indexOf('@ceo') >= 0 || c.indexOf('@ervoorz') >= 0; + }, + + formatMsgTime(m) { + var raw = (m && (m.created_at || m.createdAt || m.ts)) || ''; + if (!raw) return ''; + var d = new Date(raw); + if (isNaN(d.getTime())) return ''; + return d.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' }); + }, + + formatMsgDate(m) { + var raw = (m && (m.created_at || m.createdAt || m.ts)) || ''; + if (!raw) return ''; + var d = new Date(raw); + if (isNaN(d.getTime())) return ''; + var today = new Date(); + var yest = new Date(); yest.setDate(today.getDate() - 1); + var same = function (a, b) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + }; + if (same(d, today)) return 'Vandaag'; + if (same(d, yest)) return 'Gisteren'; + return d.toLocaleDateString('nl-NL', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); + }, + + dayKey(m) { + var raw = (m && (m.created_at || m.createdAt || m.ts)) || ''; + if (!raw) return ''; + var d = new Date(raw); + if (isNaN(d.getTime())) return ''; + return d.getFullYear() + '-' + (d.getMonth()+1) + '-' + d.getDate(); + }, + + showDaySep(idx) { + var list = this.visibleMessages(); + if (!list.length || idx < 0 || idx >= list.length) return false; + if (idx === 0) return !!this.dayKey(list[idx]); + return this.dayKey(list[idx]) !== this.dayKey(list[idx - 1]); + }, + + visibleMessages() { + if (this.screenCleared) return []; + var q = (this.chatSearch || '').trim().toLowerCase(); + var list = this.messages || []; + if (!q) return list; + return list.filter(function (m) { + var hay = [ + m.content || '', + m.agent_key || '', + m.username || '', + m.display_name || '', + m.sender_type || '', + ].join(' ').toLowerCase(); + return hay.indexOf(q) >= 0; + }); + }, + + toggleSearch() { + this.searchOpen = !this.searchOpen; + this.toolsOpen = false; + if (this.searchOpen) { + var self = this; + this.$nextTick(function () { + var el = self.$refs && self.$refs.wrSearch; + if (el) el.focus(); + }); + } else { + this.chatSearch = ''; + } + }, + + clearScreen() { + this.screenCleared = true; + this.status = 'Scherm geleegd (berichten blijven bewaard)'; + if (window.Cockpit) Cockpit.toast('Chat-scherm geleegd', 'ok'); + }, + + restoreScreen() { + this.screenCleared = false; + this.status = ''; + this.$nextTick(function () { this.scrollLog(); }.bind(this)); + }, + + async clearChatHistory() { + if (!this.room) return; + if (!window.confirm('Alle berichten in dit kanaal permanent van het scherm wissen (soft-delete)?')) return; + try { + var r = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/messages', { + method: 'DELETE', + credentials: 'same-origin', + }).then(function (x) { return x.json().then(function (d) { return { ok: x.ok, d: d }; }); }); + if (!r.ok) throw new Error((r.d && (r.d.detail || r.d.message)) || 'Wissen mislukt'); + this.messages = []; + this.screenCleared = false; + this.lastId = 0; + this.status = 'Geschiedenis gewist (' + ((r.d && r.d.cleared) || 0) + ')'; + if (window.Cockpit) Cockpit.toast('Chatgeschichte gewist', 'ok'); + } catch (e) { + if (window.Cockpit) Cockpit.toast(e.message || 'Wissen mislukt', 'error'); + } + }, + + downloadChat(fmt) { + fmt = fmt || 'txt'; + var list = this.messages || []; + if (!list.length) { + if (window.Cockpit) Cockpit.toast('Geen berichten om te downloaden', 'info'); + return; + } + var title = (this.room && (this.room.title || this.room.kind)) || 'war-room'; + var stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); + var filename = 'foodlinkk-' + String(title).replace(/[^\w\-]+/g, '_') + '-' + stamp; + var body = ''; + var mime = 'text/plain;charset=utf-8'; + if (fmt === 'json') { + body = JSON.stringify({ room: this.room, exported_at: new Date().toISOString(), messages: list }, null, 2); + filename += '.json'; + mime = 'application/json;charset=utf-8'; + } else if (fmt === 'md') { + body = '# ' + title + '\n\n_Geëxporteerd: ' + new Date().toLocaleString('nl-NL') + '_\n\n'; + list.forEach(function (m) { + var who = this.labelForMessage(m) + (m.agent_key ? (' (@' + m.agent_key + ')') : ''); + var when = this.formatMsgDate(m) + ' ' + this.formatMsgTime(m); + body += '### ' + who + ' · ' + when.trim() + '\n\n' + (m.content || '') + '\n\n---\n\n'; + }.bind(this)); + filename += '.md'; + mime = 'text/markdown;charset=utf-8'; + } else { + body = title + '\nGeëxporteerd: ' + new Date().toLocaleString('nl-NL') + '\n' + ('='.repeat(48)) + '\n\n'; + list.forEach(function (m) { + var who = this.labelForMessage(m) + (m.agent_key ? (' (@' + m.agent_key + ')') : ''); + var when = (this.formatMsgDate(m) + ' ' + this.formatMsgTime(m)).trim(); + body += '[' + when + '] ' + who + ':\n' + (m.content || '') + '\n\n'; + }.bind(this)); + filename += '.txt'; + } + var blob = new Blob([body], { type: mime }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 500); + this.toolsOpen = false; + if (window.Cockpit) Cockpit.toast('Download gestart (' + fmt + ')', 'ok'); + }, + async deleteMessage(m) { if (!this.room || !m || !m.id) return; if (!window.confirm('Bericht verwijderen?')) return; @@ -554,8 +1012,10 @@ window.WarRoomHub = (function () { removeDeletedIds(ids) { if (!ids || !ids.length) return; var set = {}; - ids.forEach(function (id) { set[id] = true; }); - this.messages = (this.messages || []).filter(function (m) { return !set[m.id]; }); + ids.forEach(function (id) { set[String(id)] = true; }); + this.messages = (this.messages || []).filter(function (m) { + return !set[String(m.id)]; + }); }, @@ -566,9 +1026,9 @@ window.WarRoomHub = (function () { return; } if (!(this.voiceRoomAgents || []).length) { - this.voiceRoomAgents = ['herman']; + this.voiceRoomAgents = ['herman', 'bizdev', 'finance', 'retail', 'marketing']; } - this.speakReplies = true; + // voorlezen blijft uit tot gebruiker 🔊 aanzet this.voiceRoomOpen = true; this.callOpen = false; this.status = 'Voice Room open — houd 🎤 in om te praten'; diff --git a/cockpit/static/js/export-intel.js b/cockpit/static/js/export-intel.js index e52ff38..89870f4 100644 --- a/cockpit/static/js/export-intel.js +++ b/cockpit/static/js/export-intel.js @@ -4,6 +4,8 @@ function exportIntelApp() { tabIndicatorTop: 0, tabIndicatorH: 40, busy: false, + overlayDismissed: false, + syncAbort: null, syncMsg: '', syncMonitor: null, syncMonitorOpen: true, @@ -291,9 +293,30 @@ function exportIntelApp() { }, isWorking() { + if (this.overlayDismissed) return false; return this.busy || this.crmPushBusy || this.mapLoading || this.drawerLoading; }, + dismissOverlay() { + this.overlayDismissed = true; + }, + + async cancelSync() { + try { + if (this.syncAbort) { + try { this.syncAbort.abort(); } catch (e) {} + } + await fetch('/api/export-intel/sync/cancel', { method: 'POST' }); + this.syncMsg = 'Sync wordt geannuleerd…'; + this.busy = false; + this.overlayDismissed = true; + if (window.Cockpit) Cockpit.toast('Sync annuleren…', 'info'); + await this.pollSyncStatus(); + } catch (e) { + this.syncMsg = 'Annuleren mislukt: ' + e.message; + } + }, + progressLabel() { if (this.busy && this.syncMsg) return this.syncMsg; if (this.crmPushBusy) return 'CRM import bezig…'; @@ -792,7 +815,9 @@ function exportIntelApp() { return; } this.busy = true; + this.overlayDismissed = false; this.syncMsg = kind === 'world' ? 'Wereld-sync gestart…' : 'Sync gestart…'; + this.syncAbort = (typeof AbortController !== 'undefined') ? new AbortController() : null; try { var body = { country_iso2: this.country || null, @@ -806,20 +831,25 @@ function exportIntelApp() { return; } var endpoint = kind === 'region' ? 'region' : kind; - var r = await fetch('/api/export-intel/sync/' + endpoint, { + var fetchOpts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - }).then((x) => x.json()); + }; + if (this.syncAbort) fetchOpts.signal = this.syncAbort.signal; + var r = await fetch('/api/export-intel/sync/' + endpoint, fetchOpts).then((x) => x.json()); if (r.error) this.syncMsg = 'Fout: ' + r.error; + else if (r.cancelled || (r.result && r.result.cancelled)) this.syncMsg = 'Sync geannuleerd'; else if (kind === 'world') this.syncMsg = 'Wereld-sync voltooid — kaart wordt ververst'; else if (kind === 'all' || kind === 'region') this.syncMsg = 'Regio-sync voltooid'; else this.syncMsg = 'Sync voltooid'; - await this.refresh(); + if (!(r.cancelled || (r.result && r.result.cancelled))) await this.refresh(); } catch (e) { - this.syncMsg = 'Sync mislukt: ' + e.message; + if (e && e.name === 'AbortError') this.syncMsg = 'Sync geannuleerd'; + else this.syncMsg = 'Sync mislukt: ' + e.message; } this.busy = false; + this.syncAbort = null; }, tabCount(id) { diff --git a/cockpit/templates/agents.html b/cockpit/templates/agents.html index efad51a..73d31ce 100644 --- a/cockpit/templates/agents.html +++ b/cockpit/templates/agents.html @@ -4,12 +4,12 @@ - - - + + + {% endblock %} {% block content %} -
+