diff --git a/docs/design_index.md b/docs/design_index.md index 23b528065..81d89b228 100644 --- a/docs/design_index.md +++ b/docs/design_index.md @@ -93,6 +93,7 @@ live. | Private Otter archive access, conversation images or research-slide reading | [Private Otter archive and conversation images](engineering/otter_archive_and_conversation_images.md) | | Reusing external ontology concepts with source identity and attribution | [KnowKat ontology sources and governed adoption](engineering/knowkat_ontology_sources.md) | | Importing external human/agent transcripts into conversation carriers | [External conversation import](engineering/external_conversation_import.md) | +| Unified conversation discovery, message exchanges, read state or participant avatars | [Unified conversations and participant profiles](engineering/unified_conversations_and_participant_profiles.md) | | Durable assertions, text/logical assertion typing, propositions, context-sensitive retrieval, hypotheses, publication or promotion, or user-, organisation-, project-, source-, theory-, or time-relative knowledge | [Assertion and propositional-sentence ontology](engineering/assertion_and_propositional_sentence_ontology.md), then [contextual knowledge evolution](engineering/contextual_knowledge_evolution.md); for canonical ontology publication or scope change, the active [ontology publication authority boundary](engineering/ontology_publication_authority.md) | | Evaluation, benchmarks, or research-sensitive architecture | [agent evaluation and research uptake](engineering/agent_evaluation_and_research_uptake.md) | | Minimal imposition, elicitation, or write policy | [minimal-imposition principle](engineering/minimal_imposition_design_principle.md) | diff --git a/docs/engineering/unified_conversations_and_participant_profiles.md b/docs/engineering/unified_conversations_and_participant_profiles.md new file mode 100644 index 000000000..c18be6aba --- /dev/null +++ b/docs/engineering/unified_conversations_and_participant_profiles.md @@ -0,0 +1,116 @@ +# Unified conversations and participant profiles + +- **Kind:** Capability and implementation boundary +- **Lifecycle:** Active +- **Owner:** Von conversation interface +- **Review trigger:** Changes to participant identity, conversation transport or avatar audience semantics +- **Native task:** `#V#task_agent_3f9f792044179b8f9c90ebeb121f7784` + +## User outcome and authority + +Conversations provides one activity-ordered list of ordinary Von conversations and +person/agent message exchanges. Selecting a row opens its appropriate reader and +composer. Persisting a visible contribution promotes the same row; viewing, +renaming, retrying a duplicate append, tool activity and progress heartbeats do not. +Pins remain a separate user preference. Incoming activity does not change selection +or discard the draft in another conversation. + +This is a shared discovery and presentation layer over the existing canonical +stores. Ordinary transcripts remain in `chat_history`; direct messages remain +Vontology message concepts. A message exchange is identified by the exact set of +participants and its organisation, not by an agent's name. Group exchanges are +readable but the existing pair-only sending capability is not presented as a group +reply. Imported actors and transcript provenance remain intact. The old Messages +tab reference redirects to Conversations; message references retain their source +and exact retrieval locator. + +The simplest adequate baseline is the existing readers and canonical APIs with a +common metadata projection. No transcript migration, new workflow, universal +conversation schema or model call is required for list maintenance. Generation is +an explicit image request, where model judgement is useful. + +## Catalogue and read behaviour + +`GET /api/messages/conversation-catalogue` merges bounded server-side keyset pages +from both stores. Its opaque cursor is bound to actor, namespace, organisation and +mailbox facet. Each source applies the common time/key boundary before limiting; +an unconsumed candidate is fetched on the next page. A source failure is reported +and does not advance the common cursor past the unavailable source. + +Ordinary conversations use persisted contribution metadata. Legacy dates are +materialised from the latest visible transcript entry by conditional, batched +derived updates without changing the transcript or its modification time. Message +exchange dates come from canonical message creation times. The foreground browser +polls on a four-second cadence without accumulating overlapping requests; exact +latency also depends on the service and network. + +The default catalogue uses the window's current context. **All organisations** +adds the viewer's message exchanges across their verified memberships; this does +not broaden ordinary conversation access. The reply destination explicitly shows +the original organisation even when it differs from the current window. + +Unread acknowledgements require visible contributions in an active document. +Ordinary conversations use per-viewer monotonic read cursors with a quiet initial +baseline; precise timestamps and stable turn IDs prevent a rounded HTTP date from +losing the read acknowledgement. Existing shared-conversation invitation receipts +remain authoritative. Direct messages retain canonical `read_by` state. Loading a +background source alone does not mark it read. + +The title/participant and Unread filters apply to loaded catalogue rows. **Load +older conversations** extends the bounded list. When further pages exist the badge +is an explicit lower bound (`N+`), not a claimed total over unexamined history. +Existing loaded pins remain visible across refreshes; discovering a pin beyond the +initial history window still requires loading older conversations. These bounded +list limitations are not a replacement for global conversation/content search. + +## Reusable identity and avatar capability + +The same profile editor is available from Settings, a participant's concept view, +a conversation header and contribution portraits. It resolves display names from +the existing concept identity, offers concept details, and supports upload, +private generation preview, explicit publication and removal. Other participants' +profiles are readable where visible; editing requires the authenticated subject's +own identity. Legacy identity headers cannot authorise profile changes. + +`participant_profile` provides agent parity through the internal MCP catalogue: +`get`, `generate`, `set_avatar` and `remove_avatar`. The HTTP entry points are +`/von/api/participants/profile`, `/profiles`, `/avatar` and `/avatar/generate`. +The image generation provider is requested once with retries disabled; a private +candidate is not automatically published. Provider image charges are described in +the editor, and are not claimed as part of an ordinary conversation's model cost. + +Avatar variants use Von's existing scope modes: + +| Scope | Mode | +|---|---| +| User | `user_only_default` | +| Organisation | `organisation_general` | +| User and organisation | `user_org_default` | +| Global | `global_general` | + +Canonical file-copy visibility enforces the audience. Matching combined, user, +organisation and global variants take precedence in that order; a variant from a +different organisation is not selected as the current organisation's portrait. +Publication creates a centred 256-pixel PNG derivative with source metadata +removed. The original upload/generated candidate stays private. Replacing or +removing a variant retires that publication, allowing another applicable variant +to become visible. Image reads validate the viewer and window context, use private +no-store caching, and never expose the private source blob URL in the public +profile projection. + +The generally reusable support added here is participant presentation, explicit +scope-aware avatar publication, common contribution rendering and copy actions, +exact exchange locators, activity metadata, and durable per-viewer read state. +These capabilities preserve source-specific affordances, including Thinking, +model controls, recommendations, tasks and original reply recipients where they +actually apply. + +## Acceptance and delivery boundary + +The release claim is bounded behaviour (Tier 1), with canonical read-back and +negative authority/scope cases for avatar publication and exchange retrieval +(Tier 2). Required evidence includes interleaved/equal-time pagination, unread +precision, draft and selection preservation, exact group/organisation identity, +profile permission denial, publication read-back, and authenticated desktop and +narrow-viewport checks. The native task holds dated delivery evidence and the +current merge decision. Repository publication does not activate a running server. diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index fccdf38e9..4c6d14ce0 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -41797,6 +41797,7 @@ def _build_default_catalogue_knowledge_io_definitions() -> List[MethodDefinition *otter_collection_definitions(), *knowkat_definitions(), *conversation_image_definitions(), + *participant_profile_definitions(), # Owner-scoped LinkedIn export search via a local external MCP server. MethodDefinition( name="linkedin_index_status", @@ -46669,6 +46670,7 @@ def _register_dynamic_catalogue_methods( from .conversation_image_tools import definitions as conversation_image_definitions +from .participant_profile_tools import definitions as participant_profile_definitions from .otter_archive_tools import definitions as otter_archive_definitions from .otter_collection_tools import definitions as otter_collection_definitions diff --git a/src/backend/integrations/internal_mcp/participant_profile_tools.py b/src/backend/integrations/internal_mcp/participant_profile_tools.py new file mode 100644 index 000000000..d69fc0492 --- /dev/null +++ b/src/backend/integrations/internal_mcp/participant_profile_tools.py @@ -0,0 +1,65 @@ +"""Agent parity for the participant profile editor.""" + +import base64 + +from .gateway import MethodDefinition +from .schemas import Schema + + +def participant_profile(**args): + from ...services import participant_profile_service as service + from .catalogue import make_error_response + + action = args.pop("action", "get") + try: + if action == "get": + return { + "success": True, + "profile": service.get_profile(args.get("concept_id")), + } + if action == "generate": + return { + "success": True, + "image": service.generate_avatar( + prompt=args.get("prompt"), concept_id=args.get("concept_id") + ), + } + if action in ("set_avatar", "remove_avatar"): + encoded = args.get("image_base64") + if encoded and len(encoded) > 12 * 1024 * 1024: + raise ValueError("Image too large.") + profile = service.set_avatar( + concept_id=args.get("concept_id"), + image_concept_id=args.get("image_concept_id"), + data=base64.b64decode(encoded, validate=True) if encoded else None, + remove=action == "remove_avatar", + scope=args.get("scope", "global_general"), + ) + return {"success": True, "profile": profile} + raise ValueError("Unknown participant profile action.") + except (ValueError, PermissionError) as exc: + return make_error_response("participant_profile_unavailable", str(exc)) + + +def definitions(): + return [ + MethodDefinition( + name="participant_profile", + handler=participant_profile, + input_schema=Schema( + required={}, + optional={ + "action": str, + "concept_id": str, + "prompt": str, + "scope": str, + "image_concept_id": str, + "image_base64": str, + }, + allow_unknown=False, + ), + output_schema=Schema(required={}, optional={}, allow_unknown=True), + category="write", + description="Read a participant profile (action=get), generate a private avatar preview (generate, prompt), publish an avatar (set_avatar with private image_concept_id or PNG/JPEG/WebP image_base64), or remove_avatar. Defaults to the authenticated actor; edits only their own profile. Generation uses one paid image request and does not publish automatically. Scope selects user_only_default, organisation_general, user_org_default (Von combined audience) or global_general. Publishing creates a sanitised 256px derivative with that scope; the private original stays private. Returns canonical profile readback. Use from conversations, concept views or settings.", + ) + ] diff --git a/src/backend/server/routes/message_routes.py b/src/backend/server/routes/message_routes.py index bb095233e..bb1cf9b10 100644 --- a/src/backend/server/routes/message_routes.py +++ b/src/backend/server/routes/message_routes.py @@ -361,9 +361,6 @@ def send_message() -> ResponseReturnValue: ), 409, ) - if not isinstance(org_id, str) or not org_id: - return jsonify({"error": "No organisation context"}), 400 - recipient_ids = _normalise_recipient_ids(data.get("recipient_ids")) content = data.get("content", "").strip() @@ -371,6 +368,14 @@ def send_message() -> ResponseReturnValue: return jsonify({"error": "At least one recipient is required"}), 400 if not content: return jsonify({"error": "Message content is required"}), 400 + if not isinstance(org_id, str) or not org_id: + return jsonify( + error="Choose a shared organisation for this message", + error_code="message_organisation_required", + common_organisation_options=_build_common_organisation_options( + sender_id=sender_id, recipient_ids=recipient_ids + ), + ), 409 if "idempotency_key" in data: return ( jsonify( @@ -512,6 +517,26 @@ def send_message() -> ResponseReturnValue: # Return sanitised response delivery_status = "reused" if reused else "created" + from ...services.message_catalogue_service import exchange_id + from ...services.chat_history_service import _coerce_datetime + + participants = sorted(set([sender_id, *recipient_ids])) + published_at = _coerce_datetime(message.get("created_at")) + conversation = { + "session_id": exchange_id(participants, org_id), + "source_kind": "message_exchange", + "participant_ids": participants, + "other_participant_ids": [p for p in participants if p != sender_id], + "viewer_id": sender_id, + "organisation_concept_id": org_id, + "session_name": ", ".join( + p.removeprefix("#V#").replace("_", " ") for p in recipient_ids + ), + "last_message_id": message.get("concept_id"), + "last_message_at": published_at.isoformat() if published_at else None, + "last_author_id": sender_id, + "preview": content[:240], + } return ( jsonify( { @@ -522,6 +547,7 @@ def send_message() -> ResponseReturnValue: "idempotent_replay": reused, "delivery_status": delivery_status, "message_id": message.get("concept_id"), + "conversation": conversation, "sent_at": message.get("concept_data", {}).get("sent_at"), "attribution": message.get("concept_data", {}).get( "attribution", @@ -920,3 +946,127 @@ def search() -> ResponseReturnValue: ), 200, ) + + +@message_bp.route("/conversation-catalogue", methods=["GET"]) +def unified_conversation_catalogue(): + from ...services.conversation_catalogue_service import list_catalogue + + actor = _get_current_user_concept_id() + if not actor: + return jsonify(error="authentication_required"), 401 + try: + effective = get_effective_context( + request.headers.get("X-Von-Window-Session"), + dict(session), + actor, + require_known_window=True, + ) + from ...services.namespace_service import derive_namespace_for_actor + + namespace = effective.get("namespace") or derive_namespace_for_actor( + actor, effective.get("organisation_id") + ) + return jsonify( + list_catalogue( + actor, + namespace=namespace, + organisation=_get_current_org_concept_id( + actor, require_known_window=True + ), + limit=request.args.get("limit", 100, type=int), + cursor=request.args.get("cursor"), + all_contexts=request.args.get("all_contexts") == "true", + ) + ) + except (ValueError, PermissionError) as exc: + return jsonify(error=str(exc)), 400 + + +@message_bp.route("/catalogue", methods=["GET"]) +def message_catalogue(): + from ...services.message_catalogue_service import list_exchanges + + actor = _get_current_user_concept_id() + if not actor: + return jsonify(error="authentication_required"), 401 + try: + result = list_exchanges( + actor, + organisation=_get_current_org_concept_id(actor, require_known_window=True), + limit=max(1, min(200, request.args.get("limit", 100, type=int))), + skip=max(0, request.args.get("skip", 0, type=int)), + query_text=request.args.get("q"), + all_contexts=request.args.get("all_contexts") == "true", + ) + return jsonify(**result, current_user_id=actor) + except Exception: + _log.exception("Message catalogue unavailable") + return jsonify( + error="messages_unavailable", conversations=[], coverage_complete=False + ), 503 + + +@message_bp.route("/exchange", methods=["POST"]) +def message_exchange(): + from ...services.message_catalogue_service import get_exchange + + actor = _get_current_user_concept_id() + if not actor: + return jsonify(error="authentication_required"), 401 + payload = request.get_json(silent=True) or {} + try: + result = get_exchange( + actor, + payload.get("participant_ids"), + organisation=( + _normalise_concept_id(payload["organisation_concept_id"]) + if "organisation_concept_id" in payload + else _get_current_org_concept_id(actor, require_known_window=True) + ), + limit=50, + before=payload.get("before"), + ) + for message in result["messages"]: + message["_id"] = str(message.get("_id", "")) + return jsonify(result) + except PermissionError: + return jsonify(error="conversation_unavailable"), 403 + except ValueError as exc: + return jsonify(error=str(exc)), 400 + except Exception: + _log.exception("Message exchange unavailable") + return jsonify(error="messages_unavailable"), 503 + + +@message_bp.route("/exchange/preference", methods=["POST"]) +def message_exchange_preference(): + from ...services.message_catalogue_service import get_exchange, exchange_id + from ...services.conversation_management_service import set_conversation_preference + + actor = _get_current_user_concept_id() + if not actor: + return jsonify(error="authentication_required"), 401 + payload = request.get_json(silent=True) or {} + participants = payload.get("participant_ids") + action = payload.get("action") + if action not in ("pin", "unpin", "hide", "unhide"): + return jsonify(error="unsupported_action"), 400 + try: + org = ( + _normalise_concept_id(payload["organisation_concept_id"]) + if "organisation_concept_id" in payload + else _get_current_org_concept_id(actor, require_known_window=True) + ) + exchange = get_exchange(actor, participants, organisation=org, limit=1) + if not exchange["messages"]: + return jsonify(error="conversation_unavailable"), 404 + field = "pinned" if action in ("pin", "unpin") else "hidden" + result = set_conversation_preference( + actor_user_id=actor, + session_id=exchange_id(participants, org), + **{field: action in ("pin", "hide")}, + ) + return jsonify(success=True, preference=result) + except PermissionError: + return jsonify(error="conversation_unavailable"), 403 diff --git a/src/backend/server/routes/participant_profile_routes.py b/src/backend/server/routes/participant_profile_routes.py new file mode 100644 index 000000000..b08e8e9e4 --- /dev/null +++ b/src/backend/server/routes/participant_profile_routes.py @@ -0,0 +1,96 @@ +"""Common profile API for Settings, concepts, conversations and agent tools.""" + +import io +import logging + +from flask import jsonify, request, send_file, session + +from ...services import participant_profile_service as profiles +from ...services.conversation_image_service import MAX_IMAGE_BYTES + +_log = logging.getLogger(__name__) + + +def register_participant_profile_routes(blueprint): + def perform(action): + try: + return jsonify(success=True, **action()) + except PermissionError as exc: + return jsonify(error="profile_unavailable", message=str(exc)), 403 + except ValueError as exc: + return jsonify(error="invalid_profile_image", message=str(exc)), 400 + except Exception: # noqa: BLE001 - provider/storage failures are reported at the HTTP boundary + _log.exception("Participant profile operation failed") + return jsonify( + error="profile_operation_failed", + message="The profile operation failed. Your existing avatar is unchanged unless saving completed; refresh to check.", + ), 503 + + @blueprint.route("/api/participants/profile", methods=["GET"]) + def participant_profile(): + return perform( + lambda: {"profile": profiles.get_profile(request.args.get("concept_id"))} + ) + + @blueprint.route("/api/participants/profiles", methods=["POST"]) + def participant_profiles(): + return perform( + lambda: { + "profiles": profiles.get_profiles( + (request.get_json(silent=True) or {}).get("concept_ids", []) + ) + } + ) + + @blueprint.route("/api/participants/avatar", methods=["POST"]) + def participant_avatar_save(): + payload = request.get_json(silent=True) or request.form + uploaded = request.files.get("file") + return perform( + lambda: { + "profile": profiles.set_avatar( + concept_id=payload.get("concept_id"), + image_concept_id=payload.get("image_concept_id"), + remove=payload.get("remove") is True, + scope=payload.get("scope", "global_general"), + data=uploaded.read(MAX_IMAGE_BYTES + 1) if uploaded else None, + ) + } + ) + + @blueprint.route("/api/participants/avatar/generate", methods=["POST"]) + def participant_avatar_generate(): + payload = request.get_json(silent=True) or {} + return perform( + lambda: { + "image": profiles.generate_avatar( + concept_id=payload.get("concept_id"), + prompt=payload.get("prompt"), + ) + } + ) + + @blueprint.route("/api/participants//avatar", methods=["GET"]) + def participant_avatar_image(concept_id): + try: + from ...security.access_control import ( + get_effective_user_concept_id, + override_current_actor, + ) + from ...services.window_session_context_service import get_effective_context + + actor = get_effective_user_concept_id() + window_id = request.args.get("window_session_id") or request.headers.get( + "X-Von-Window-Session" + ) + context = get_effective_context( + window_id, dict(session), actor, require_known_window=True + ) + with override_current_actor(actor, context.get("organisation_id")): + data = profiles.load_avatar(concept_id) + except (PermissionError, ValueError): + return jsonify(error="avatar_unavailable"), 404 + response = send_file(io.BytesIO(data), mimetype="image/png", max_age=0) + response.headers["Cache-Control"] = "private, no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return response diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py index dfda5b1f8..43c36b12c 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -18128,6 +18128,10 @@ def _resolve_authorised_active_session_id( reverse=True, ) + from ...services.conversation_read_service import project_unread + + combined = project_unread(user_concept_id, combined) + response_payload: dict[str, Any] = { "authenticated": True, "sessions": combined, @@ -21581,3 +21585,50 @@ def _contains_openai_quota_error(message: object) -> bool: from .conversation_image_routes import register_image_routes register_image_routes(von_bp) + +from .participant_profile_routes import register_participant_profile_routes + +register_participant_profile_routes(von_bp) + + +@von_bp.route("/history/read", methods=["POST"]) +def acknowledge_conversation_read(): + from ...security.access_control import get_effective_user_concept_id + from ...services.conversation_read_service import acknowledge + + actor = get_effective_user_concept_id() + if not actor: + return jsonify(error="authentication_required"), 401 + payload = request.get_json(silent=True) or {} + sid = payload.get("session_id") + if not isinstance(sid, str) or not sid: + return jsonify(error="session_required"), 400 + effective = get_effective_context( + request.headers.get("X-Von-Window-Session"), + dict(session), + actor, + require_known_window=True, + ) + summary = chat_history_service.get_chat_history_session_summary( + actor, + sid, + namespace=effective.get("namespace"), + include_legacy=False, + summary_mode="light", + ) + if not summary: + return jsonify(error="conversation_unavailable"), 404 + observed = chat_history_service._coerce_datetime(payload.get("observed_timestamp")) + expected = chat_history_service._coerce_datetime( + summary.get("last_incoming_timestamp") + ) + turn_matches = bool( + payload.get("observed_turn_id") + and payload["observed_turn_id"] == summary.get("last_incoming_turn_id") + ) + if not turn_matches and (not observed or not expected or observed != expected): + return jsonify(error="contribution_changed"), 409 + return jsonify( + success=True, + read_at=acknowledge(actor, sid, summary["last_incoming_contribution_at"]), + ) diff --git a/src/backend/services/chat_history_service.py b/src/backend/services/chat_history_service.py index 73557b31b..f052f9a04 100644 --- a/src/backend/services/chat_history_service.py +++ b/src/backend/services/chat_history_service.py @@ -9,7 +9,7 @@ import uuid from datetime import datetime, timezone from typing import List, Dict, Any, Optional, Iterable, Mapping -from pymongo import ASCENDING, DESCENDING +from pymongo import ASCENDING, DESCENDING, UpdateOne from pymongo.errors import ( DuplicateKeyError, OperationFailure, @@ -598,6 +598,16 @@ def _ensure_chat_history_indexes(collection) -> None: ], name="user_id_1_session_id_1_namespace_1", ) + if "namespace_user_contribution_session" not in existing_indexes: + collection.create_index( + [ + ("namespace", ASCENDING), + ("user_id", ASCENDING), + ("last_contribution_at", DESCENDING), + ("session_id", ASCENDING), + ], + name="namespace_user_contribution_session", + ) if "user_id_1_updated_at_-1" not in existing_indexes: collection.create_index( [("user_id", ASCENDING), ("updated_at", DESCENDING)], @@ -1794,6 +1804,9 @@ def _split_history_into_segments_with_locations( "content": entry.get("content"), "timestamp": entry.get("timestamp"), } + precise_timestamp = _coerce_datetime(entry.get("timestamp")) + if precise_timestamp: + copied["contribution_timestamp"] = precise_timestamp.isoformat() if isinstance(entry.get("image_attachments"), list): copied["image_attachments"] = list(entry["image_attachments"]) turn_id = entry.get("turn_id") @@ -2993,6 +3006,13 @@ def get_chat_history_telemetry_locator_projection( "organisation_concept_id": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "history": compact_history_projection, "conversation_situation_state": { "available": { @@ -3147,6 +3167,13 @@ def get_chat_history_segments( "session_name": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "conversation_situation": 1, "conversation_observations": 1, "conversation_observation_total": 1, @@ -3178,6 +3205,13 @@ def get_chat_history_segments( "session_name": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "conversation_situation": 1, "conversation_observations": 1, "conversation_observation_total": 1, @@ -3340,6 +3374,13 @@ def get_chat_history_transcript_page( "session_name": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "history_length": {"$size": _history_array_expr()}, "history_page": { "$slice": [ @@ -3451,6 +3492,13 @@ def get_chat_history_title_evidence( "session_name": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "history_length": {"$size": _history_array_expr()}, "user_messages": { "$filter": { @@ -3467,14 +3515,17 @@ def get_chat_history_title_evidence( "session_name": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "history_length": 1, "user_message_count": {"$size": "$user_messages"}, - "first_user_message": { - "$arrayElemAt": ["$user_messages", 0] - }, - "latest_user_message": { - "$arrayElemAt": ["$user_messages", -1] - }, + "first_user_message": {"$arrayElemAt": ["$user_messages", 0]}, + "latest_user_message": {"$arrayElemAt": ["$user_messages", -1]}, } }, ] @@ -3910,6 +3961,32 @@ def _upsert_turn_execution_projection_for_message( ) +def _conversation_activity_fields(message, owner_user_id, published_at): + """Activity is a persisted visible contribution, never a tool heartbeat.""" + role = message.get("role") + content = str(message.get("content") or "").strip() + attachments = message.get("image_attachments") + if role not in {"user", "assistant"} or not (content or attachments): + return {} + fields = { + "last_contribution_at": published_at, + "last_contribution_preview": content[:240] or "Image attachment", + "last_contribution_role": role, + "last_contribution_author": message.get("author_user_id"), + } + if role == "assistant" or ( + message.get("author_user_id") and message["author_user_id"] != owner_user_id + ): + fields.update( + { + "last_incoming_contribution_at": published_at, + "last_incoming_timestamp": message.get("timestamp"), + "last_incoming_turn_id": message.get("turn_id"), + } + ) + return fields + + def add_message_to_history( user_id: str, session_id: str, @@ -4037,6 +4114,11 @@ def add_message_to_history( ) set_fields: Dict[str, Any] = {"updated_at": datetime.now(timezone.utc)} + set_fields.update( + _conversation_activity_fields( + stored_message, user_id, set_fields["updated_at"] + ) + ) search_segment = _conversation_search_segment(stored_message) search_text = search_segment.get("text") if search_segment is not None else None if search_text is not None: @@ -4318,6 +4400,9 @@ def append_message_to_history_once( stored_message["timestamp"] = datetime.now(timezone.utc) set_fields: Dict[str, Any] = {"updated_at": datetime.now(timezone.utc)} + set_fields.update( + _conversation_activity_fields(stored_message, user_id, set_fields["updated_at"]) + ) push_fields: Dict[str, Any] = {"history": stored_message} search_segment = _conversation_search_segment(stored_message) if search_segment is not None: @@ -5373,7 +5458,11 @@ def _build_session_summary_from_metadata( session_name = _normalise_session_name(doc.get("session_name")) focus = _focus_projection_from_doc(doc) created_ts = _infer_created_timestamp(doc) - last_ts = _coerce_datetime(doc.get("updated_at")) or created_ts + last_ts = ( + _coerce_datetime(doc.get("last_contribution_at")) + or _coerce_datetime(doc.get("updated_at")) + or created_ts + ) # Metadata-only summaries intentionally avoid history reads for resilience. if ( @@ -5405,7 +5494,20 @@ def _build_session_summary_from_metadata( "conversation_search_index_version": doc.get( "conversation_search_index_version" ), - "preview": None, + "preview": doc.get("last_contribution_preview"), + "last_contribution_role": doc.get("last_contribution_role"), + "last_contribution_author": doc.get("last_contribution_author"), + "last_incoming_contribution_at": _coerce_datetime( + doc.get("last_incoming_contribution_at") + ).isoformat() + if _coerce_datetime(doc.get("last_incoming_contribution_at")) + else None, + "last_incoming_turn_id": doc.get("last_incoming_turn_id"), + "last_incoming_timestamp": _coerce_datetime( + doc.get("last_incoming_timestamp") + ).isoformat() + if _coerce_datetime(doc.get("last_incoming_timestamp")) + else None, **focus, **provenance, **_concept_q_and_a_metadata_field(doc), @@ -5419,6 +5521,7 @@ def _get_chat_history_session_summaries_metadata_only( query: Dict[str, Any], safe_limit: int | None, ) -> List[Dict[str, Any]]: + backfill_conversation_activity_metadata(chat_history_coll, query=query) projection: Dict[str, Any] = _add_chat_session_provenance_projection( { "_id": 0, @@ -5426,6 +5529,13 @@ def _get_chat_history_session_summaries_metadata_only( "session_name": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "namespace": 1, "organisation_concept_id": 1, "trashed_at": 1, @@ -5443,7 +5553,7 @@ def _get_chat_history_session_summaries_metadata_only( ) try: cursor = cursor.sort( - [("updated_at", DESCENDING), ("created_at", DESCENDING)] + [("last_contribution_at", DESCENDING), ("session_id", ASCENDING)] ) except Exception: pass @@ -5477,6 +5587,105 @@ def _get_chat_history_session_summaries_metadata_only( return summaries[:safe_limit] +def backfill_conversation_activity_metadata(collection, *, query): + """Materialise legacy contribution dates once, without touching meaning or mtime. + + This is actor-scoped derived maintenance. Mongo projects one visible message + per legacy session, not full histories; writes are batched and conditional so + a concurrent new contribution wins. Read receipts get no historical flood. + """ + if not callable(getattr(collection, "bulk_write", None)): + return # Minimal read-only adapters can still return their metadata. + missing = {"$and": [query, {"last_contribution_at": {"$exists": False}}]} + while True: + docs = list( + _read_aggregate( + collection, + [ + {"$match": missing}, + {"$limit": 200}, + { + "$project": { + "_id": 1, + "created_at": 1, + "updated_at": 1, + "last": { + "$arrayElemAt": [ + { + "$filter": { + "input": {"$ifNull": ["$history", []]}, + "as": "entry", + "cond": { + "$and": [ + { + "$in": [ + "$$entry.role", + ["user", "assistant"], + ] + }, + {"$ne": ["$$entry.content", ""]}, + ] + }, + } + }, + -1, + ] + }, + } + }, + { + "$project": { + "_id": 1, + "created_at": 1, + "updated_at": 1, + "timestamp": "$last.timestamp", + "role": "$last.role", + "author": "$last.author_user_id", + "preview": { + "$substrCP": [ + { + "$convert": { + "input": "$last.content", + "to": "string", + "onError": "", + "onNull": "", + } + }, + 0, + 240, + ] + }, + } + }, + ], + operation="backfill_conversation_activity_metadata.aggregate", + ) + ) + if not docs: + return + updates = [] + for doc in docs: + timestamp = ( + _coerce_datetime(doc.get("timestamp")) + or _coerce_datetime(doc.get("created_at")) + or datetime(1970, 1, 1, tzinfo=timezone.utc) + ) + updates.append( + UpdateOne( + {"_id": doc["_id"], "last_contribution_at": {"$exists": False}}, + { + "$set": { + "last_contribution_at": timestamp, + "last_contribution_preview": doc.get("preview"), + "last_contribution_role": doc.get("role"), + "last_contribution_author": doc.get("author"), + } + }, + ) + ) + collection.bulk_write(updates, ordered=False) + + def get_chat_history_session_summaries_by_ids( user_id: str, session_ids: Iterable[str], @@ -5559,7 +5768,15 @@ def get_chat_history_session_summaries_page( { "$set": { "_conversation_page_timestamp": { - "$ifNull": ["$updated_at", {"$ifNull": ["$created_at", epoch]}] + "$ifNull": [ + "$last_contribution_at", + { + "$ifNull": [ + "$updated_at", + {"$ifNull": ["$created_at", epoch]}, + ] + }, + ] } } }, @@ -5595,6 +5812,13 @@ def get_chat_history_session_summaries_page( "session_name": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "namespace": 1, "organisation_concept_id": 1, "trashed_at": 1, @@ -5733,6 +5957,13 @@ def _load_chat_history_session_summaries( "history": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "namespace": 1, "organisation_concept_id": 1, "trashed_at": 1, @@ -6016,6 +6247,13 @@ def get_chat_history_session_summary( "session_name": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "namespace": 1, "organisation_concept_id": 1, "focal_concept_ids": 1, @@ -6065,6 +6303,13 @@ def get_chat_history_session_summary( "history": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "namespace": 1, "organisation_concept_id": 1, "session_name": 1, @@ -6539,6 +6784,13 @@ def set_chat_session_trashed( "organisation_concept_id": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "trashed_at": 1, }, ) @@ -6844,6 +7096,13 @@ def list_chat_sessions_for_focal_concept( "session_name": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "namespace": 1, "organisation_concept_id": 1, "focal_concept_ids": 1, @@ -6919,6 +7178,13 @@ def get_chat_history_session_summaries_for_owner_sessions( "session_name": 1, "created_at": 1, "updated_at": 1, + "last_contribution_at": 1, + "last_contribution_preview": 1, + "last_contribution_role": 1, + "last_contribution_author": 1, + "last_incoming_contribution_at": 1, + "last_incoming_timestamp": 1, + "last_incoming_turn_id": 1, "namespace": 1, "organisation_concept_id": 1, "trashed_at": 1, diff --git a/src/backend/services/conversation_catalogue_service.py b/src/backend/services/conversation_catalogue_service.py new file mode 100644 index 000000000..b56ed466e --- /dev/null +++ b/src/backend/services/conversation_catalogue_service.py @@ -0,0 +1,198 @@ +"""Bounded, activity-ordered catalogue over the two canonical stores. + +Each source applies the same keyset boundary before limiting. Only metadata is +merged; an unconsumed candidate is fetched again on the next page, never lost +behind a browser-side first-page truncation. Cursors are bound to actor/scope. +""" + +import logging +from datetime import UTC, datetime + +from . import chat_history_service as chats +from . import message_catalogue_service as messages +from .conversation_management_service import apply_conversation_preferences +from .conversation_read_service import project_unread +from .opaque_cursor_service import decode_opaque_cursor, encode_opaque_cursor +from .organisation_membership_service import get_user_memberships +from .shared_conversation_service import list_accepted_invites_for_user + +log = logging.getLogger(__name__) + + +def _chat_page(actor, namespace, organisation, limit, before): + owned = chats.build_chat_history_query( + user_id=actor, namespace=namespace, include_legacy=False + ) + invites = list_accepted_invites_for_user(user_concept_id=actor) + authorised = {} + if invites: + memberships = { + m.get("organisation_concept_id") + for m in get_user_memberships(actor).get("memberships", []) + } + for invite in invites: + org = invite.get("organisation_concept_id") + if org and (org != organisation or org not in memberships): + continue + owner = invite.get("conversation_owner_user_id") or invite.get( + "inviter_user_id" + ) + sid = invite.get("session_id") + if owner and sid: + authorised[(owner, sid)] = invite + # Suppress local invitation stubs; fetch only the authorised owner's row. + if authorised: + owned["session_id"] = {"$nin": [sid for _, sid in authorised]} + clauses = [ + owned, + *({"user_id": owner, "session_id": sid} for owner, sid in authorised), + ] + query = {"$or": clauses, "trashed_at": None} + collection = chats.get_chat_history_collection_service(read_only=True) + if collection is None: + raise RuntimeError("Conversations unavailable.") + chats.backfill_conversation_activity_metadata(collection, query=query) + if before: + stamp = chats._coerce_datetime(before["timestamp"]) + query["$and"] = [ + { + "$or": [ + {"last_contribution_at": {"$lt": stamp}}, + { + "last_contribution_at": stamp, + "$expr": { + "$gt": [ + {"$concat": ["chat:", "$session_id"]}, + before["key"], + ] + }, + }, + ] + } + ] + projection = chats._add_chat_session_provenance_projection( + { + key: 1 + for key in ( + "user_id", + "session_id", + "session_name", + "created_at", + "updated_at", + "namespace", + "organisation_concept_id", + "last_contribution_at", + "last_contribution_preview", + "last_contribution_role", + "last_contribution_author", + "last_incoming_contribution_at", + "last_incoming_timestamp", + "last_incoming_turn_id", + "focal_concept_ids", + "trashed_at", + ) + } + ) + projection["_id"] = 0 + docs = list( + chats._read_find( + collection, query, projection, operation="conversation_catalogue.metadata" + ) + .sort([("last_contribution_at", -1), ("session_id", 1)]) + .limit(limit + 1) + ) + rows = [] + for doc in docs[:limit]: + row = chats._build_session_summary_from_metadata(doc) + if not row: + continue + row.update( + source_kind="chat_session", catalogue_sort_key="chat:" + row["session_id"] + ) + invite = authorised.get((doc.get("user_id"), row["session_id"])) + if invite: + row.update( + shared_with_me=True, + shared_owner_user_id=doc["user_id"], + shared_from_user_id=invite.get("inviter_user_id"), + invite_id=invite.get("invite_id"), + ) + rows.append(row) + return project_unread( + actor, apply_conversation_preferences(actor_user_id=actor, conversations=rows) + ), len(docs) > limit + + +def list_catalogue( + actor, *, namespace, organisation=None, limit=100, cursor=None, all_contexts=False +): + if not actor or not namespace: + raise PermissionError("Authenticated conversation context required.") + limit = max(1, min(100, int(limit))) + scope = { + "actor": actor, + "namespace": namespace, + "organisation": organisation, + "all_contexts": all_contexts, + } + before = None + if cursor: + decoded = decode_opaque_cursor(cursor=cursor, purpose="conversation_catalogue") + if decoded.get("scope") != scope: + raise ValueError("The conversation cursor belongs to another context.") + before = decoded["position"] + rows, coverage, more = [], {}, False + try: + chat_rows, chat_more = _chat_page(actor, namespace, organisation, limit, before) + rows.extend(chat_rows) + more |= chat_more + coverage["conversations"] = True + except Exception: + log.exception("Conversation catalogue source unavailable") + coverage["conversations"] = False + try: + page = messages.list_exchanges( + actor, + organisation=organisation, + limit=limit, + before=before, + all_contexts=all_contexts, + ) + rows.extend(page["conversations"]) + more |= page["has_more"] + coverage["messages"] = True + except Exception: + log.exception("Message catalogue source unavailable") + coverage["messages"] = False + epoch = datetime(1970, 1, 1, tzinfo=UTC) + rows.sort( + key=lambda row: ( + -(chats._coerce_datetime(row.get("last_message_at")) or epoch).timestamp(), + row["catalogue_sort_key"], + ) + ) + more |= len(rows) > limit + selected = rows[:limit] + next_cursor = None + # A failed source cannot be skipped by advancing the common boundary. Keep + # the healthy source usable and require refresh to recover full coverage. + if more and selected and all(coverage.values()): + last = selected[-1] + next_cursor = encode_opaque_cursor( + purpose="conversation_catalogue", + payload={ + "scope": scope, + "position": { + "timestamp": last["last_message_at"], + "key": last["catalogue_sort_key"], + }, + }, + ttl_seconds=86400, + ) + return { + "conversations": selected, + "has_more": more, + "next_cursor": next_cursor, + "coverage": coverage, + "coverage_complete": all(coverage.values()), + } diff --git a/src/backend/services/conversation_read_service.py b/src/backend/services/conversation_read_service.py new file mode 100644 index 000000000..3a24842a8 --- /dev/null +++ b/src/backend/services/conversation_read_service.py @@ -0,0 +1,68 @@ +"""Per-viewer read cursors for ordinary conversations, with a quiet baseline.""" + +import hashlib +from datetime import UTC, datetime + +from ..db.mongo_setup import get_application_settings_collection +from . import settings_service + + +def _key(actor, session_id): + return ( + "conversation_read:" + + hashlib.sha256(f"{actor}\0{session_id}".encode()).hexdigest() + ) + + +def project_unread(actor, rows): + baseline_key = _key(actor, "__baseline__") + keys = [_key(actor, row["session_id"]) for row in rows] + values = settings_service.get_settings_batch([baseline_key, *keys]) + baseline = values.get(baseline_key) + if not baseline: + collection = get_application_settings_collection() + if collection is None: + raise RuntimeError("Read state unavailable.") + collection.update_one( + {"setting_name": baseline_key}, + { + "$setOnInsert": { + "value": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(UTC), + } + }, + upsert=True, + ) + baseline = settings_service.get_setting(baseline_key) + from .chat_history_service import _coerce_datetime + + baseline_at = _coerce_datetime(baseline) + for row, key in zip(rows, keys): + if row.get("shared_with_me"): + continue # Existing invitation receipts remain authoritative. + last = _coerce_datetime(row.get("last_incoming_contribution_at")) + read = _coerce_datetime(values.get(key)) or baseline_at + row["shared_unread_count"] = int(bool(last and read and last > read)) + return rows + + +def acknowledge(actor, session_id, published_at): + from .chat_history_service import _coerce_datetime + + timestamp = _coerce_datetime(published_at) + if timestamp is None: + raise ValueError("Invalid contribution timestamp.") + collection = get_application_settings_collection() + if collection is None: + raise RuntimeError("Read state unavailable.") + # Canonical settings value; max makes concurrent acknowledgements monotonic. + key = _key(actor, session_id) + collection.update_one( + {"setting_name": key}, + { + "$max": {"value": timestamp.isoformat()}, + "$set": {"updated_at": datetime.now(UTC)}, + }, + upsert=True, + ) + return settings_service.get_setting(key) diff --git a/src/backend/services/message_catalogue_service.py b/src/backend/services/message_catalogue_service.py new file mode 100644 index 000000000..9defec1c9 --- /dev/null +++ b/src/backend/services/message_catalogue_service.py @@ -0,0 +1,320 @@ +"""Exact participant exchanges projected from canonical message concepts. + +No transcript copy is maintained. Group recipients are part of the identity, +so a group message cannot be accidentally opened or replied to as a private DM. +""" + +from __future__ import annotations + +import hashlib +import json +import re + +from ..db.mongo_client import get_concepts_collection +from ..security.access_control import ( + apply_concept_query_filter, + get_effective_organisation_concept_id, + override_current_actor, +) +from .message_service import ( + MESSAGE_TYPE_CONCEPT_ID, + PREDICATE_RECIPIENT, + PREDICATE_SENDER, +) +from .organisation_membership_service import get_user_memberships + + +def participant_expression(): + return { + "$setUnion": [ + {"$ifNull": [f"$relationships.{PREDICATE_SENDER}", []]}, + {"$ifNull": [f"$relationships.{PREDICATE_RECIPIENT}", []]}, + ] + } + + +def exchange_id(participants, organisation=None): + return ( + "messages:" + + hashlib.sha256( + json.dumps([sorted(set(participants)), organisation]).encode() + ).hexdigest()[:32] + ) + + +def _query(actor, organisation=None, *, all_contexts=False, include_legacy=False): + query = { + "relationships.is_an_instance_of": MESSAGE_TYPE_CONCEPT_ID, + "concept_data.deleted": {"$ne": True}, + "$or": [ + {f"relationships.{PREDICATE_SENDER}": actor}, + {f"relationships.{PREDICATE_RECIPIENT}": actor}, + ], + } + if all_contexts: + orgs = { + row.get("organisation_concept_id") + for row in get_user_memberships(actor).get("memberships", []) + } + visible = [] + for org in [None, *sorted(org for org in orgs if org)]: + with override_current_actor(actor, org): + visible.append(apply_concept_query_filter(dict(query))) + return {"$or": visible} + if organisation is None or not include_legacy: + query["concept_data.organisation_concept_id"] = organisation + else: + query["$and"] = [ + { + "$or": [ + {"concept_data.organisation_concept_id": organisation}, + {"concept_data.organisation_concept_id": {"$exists": False}}, + ] + } + ] + if organisation == get_effective_organisation_concept_id(): + return apply_concept_query_filter(query) + memberships = ( + { + row.get("organisation_concept_id") + for row in get_user_memberships(actor).get("memberships", []) + } + if organisation + else set() + ) + with override_current_actor( + actor, organisation if organisation in memberships else None + ): + return apply_concept_query_filter(query) + + +def list_exchanges( + actor, + *, + organisation=None, + limit=100, + skip=0, + query_text=None, + all_contexts=False, + before=None, +): + if not actor: + raise PermissionError("Authentication required.") + collection = get_concepts_collection() + if collection is None: + raise RuntimeError("Messages unavailable.") + query = _query(actor, organisation, all_contexts=all_contexts, include_legacy=True) + if query_text: + query["concept_data.content_fallback"] = { + "$regex": re.escape(query_text[:200]), + "$options": "i", + } + pipeline = [ + {"$match": query}, + {"$sort": {"created_at": -1, "concept_id": -1}}, + { + "$group": { + "_id": { + "participants": { + "$sortArray": {"input": participant_expression(), "sortBy": 1} + }, + "organisation": { + "$ifNull": ["$concept_data.organisation_concept_id", None] + }, + }, + "last_message": {"$first": "$$ROOT"}, + "message_count": {"$sum": 1}, + "unread_count": { + "$sum": { + "$cond": [ + { + "$and": [ + { + "$in": [ + actor, + { + "$ifNull": [ + f"$relationships.{PREDICATE_RECIPIENT}", + [], + ] + }, + ] + }, + { + "$not": [ + { + "$in": [ + actor, + { + "$ifNull": [ + "$concept_data.read_by", + [], + ] + }, + ] + } + ] + }, + ] + }, + 1, + 0, + ] + } + }, + } + }, + { + "$set": { + "catalogue_sort_key": { + "$concat": [ + "message:", + { + "$reduce": { + "input": "$_id.participants", + "initialValue": "", + "in": {"$concat": ["$$value", "$$this", "\u0000"]}, + } + }, + {"$ifNull": ["$_id.organisation", ""]}, + ] + } + } + }, + ] + if before: + from .chat_history_service import _coerce_datetime + + stamp = _coerce_datetime(before["timestamp"]) + pipeline.append( + { + "$match": { + "$or": [ + {"last_message.created_at": {"$lt": stamp}}, + { + "last_message.created_at": stamp, + "catalogue_sort_key": {"$gt": before["key"]}, + }, + ] + } + } + ) + pipeline.extend( + [ + {"$sort": {"last_message.created_at": -1, "catalogue_sort_key": 1}}, + {"$skip": max(0, skip)}, + {"$limit": min(200, max(1, limit)) + 1}, + { + "$project": { + "_id": 1, + "catalogue_sort_key": 1, + "message_count": 1, + "unread_count": 1, + "last_message.concept_id": 1, + "last_message.created_at": 1, + "last_message.concept_data.content_fallback": 1, + "last_message.concept_data.organisation_concept_id": 1, + f"last_message.relationships.{PREDICATE_SENDER}": 1, + } + }, + ] + ) + rows = list(collection.aggregate(pipeline)) + has_more = len(rows) > limit + result = [] + for row in rows[:limit]: + participants = row["_id"]["participants"] + other = [p for p in participants if p != actor] + last = row["last_message"] + metadata = last.get("concept_data") or {} + from .chat_history_service import _coerce_datetime + + stamp = _coerce_datetime(last.get("created_at")) + result.append( + { + "session_id": exchange_id( + participants, metadata.get("organisation_concept_id") + ), + "source_kind": "message_exchange", + "catalogue_sort_key": row["catalogue_sort_key"], + "participant_ids": participants, + "other_participant_ids": other, + "viewer_id": actor, + "session_name": ", ".join( + p.removeprefix("#V#").replace("_", " ") for p in other + ) + or "Notes to yourself", + "last_message_at": stamp.isoformat() + if hasattr(stamp, "isoformat") + else stamp, + "last_message_id": last.get("concept_id"), + "preview": metadata.get("content_fallback", "")[:240], + "last_author_id": ( + last.get("relationships", {}).get(PREDICATE_SENDER) or [None] + )[0], + "organisation_concept_id": metadata.get("organisation_concept_id"), + "message_count": row["message_count"], + "shared_unread_count": row["unread_count"], + } + ) + from .conversation_management_service import apply_conversation_preferences + + result = apply_conversation_preferences(actor_user_id=actor, conversations=result) + return { + "conversations": result, + "has_more": has_more, + "next_skip": skip + limit if has_more else None, + } + + +def get_exchange(actor, participants, *, organisation=None, limit=50, before=None): + if ( + not actor + or not isinstance(participants, list) + or actor not in participants + or len(participants) > 50 + ): + raise PermissionError("Conversation unavailable.") + query = _query(actor, organisation) + query["$expr"] = {"$setEquals": [participant_expression(), participants]} + if before: + from .chat_history_service import _coerce_datetime + + timestamp = _coerce_datetime(before.get("created_at")) + if not timestamp or not isinstance(before.get("concept_id"), str): + raise ValueError("Invalid message cursor.") + query.setdefault("$and", []).append( + { + "$or": [ + {"created_at": {"$lt": timestamp}}, + { + "created_at": timestamp, + "concept_id": {"$lt": before["concept_id"]}, + }, + ] + } + ) + collection = get_concepts_collection() + if collection is None: + raise RuntimeError("Messages unavailable.") + rows = list( + collection.find(query) + .sort([("created_at", -1), ("concept_id", -1)]) + .limit(limit + 1) + ) + more = len(rows) > limit + rows = rows[:limit] + cursor = ( + { + "created_at": rows[-1]["created_at"].isoformat(), + "concept_id": rows[-1]["concept_id"], + } + if more and rows + else None + ) + return { + "messages": list(reversed(rows)), + "has_more": more, + "before": cursor, + "current_user_id": actor, + } diff --git a/src/backend/services/participant_profile_service.py b/src/backend/services/participant_profile_service.py new file mode 100644 index 000000000..d2ca76b8c --- /dev/null +++ b/src/backend/services/participant_profile_service.py @@ -0,0 +1,289 @@ +"""Participant presentation, independent of conversation transport. + +The concept remains the identity authority. An avatar is an explicitly published, +metadata-free derivative with the concept's audience, never a private image URL. +""" + +from __future__ import annotations + +import base64 +import hashlib +import io +from urllib.parse import quote + +from PIL import Image, ImageOps + +from ..db.repositories.concepts_repository import ConceptsRepository +from ..security.access_control import ( + AUTHENTICATED_SESSION_ACTOR_SOURCE, + AUTHENTICATED_SESSION_DERIVED_ACTOR_SOURCE, + TRUSTED_IN_PROCESS_ACTOR_SOURCE, + get_effective_organisation_concept_id, + get_effective_user_concept_id, + get_effective_user_concept_id_with_source, +) +from .conversation_image_service import inspect_image, load_image, store_image + + +def _participant(concept_id: str | None = None, *, edit: bool = False): + actor = get_effective_user_concept_id() + if edit and not _can_edit_profile(actor): + raise PermissionError( + "An authenticated participant is required to change a profile." + ) + concept_id = concept_id or actor + if not actor or not concept_id or (edit and concept_id != actor): + raise PermissionError( + "Only the signed-in participant can change their profile." + ) + doc = ConceptsRepository.find_one({"concept_id": concept_id}) + if not doc: + raise PermissionError("Participant unavailable.") + return actor, concept_id, doc + + +def _can_edit_profile(actor): + resolved, source = get_effective_user_concept_id_with_source() + return bool( + actor + and resolved == actor + and source + in { + AUTHENTICATED_SESSION_ACTOR_SOURCE, + AUTHENTICATED_SESSION_DERIVED_ACTOR_SOURCE, + TRUSTED_IN_PROCESS_ACTOR_SOURCE, + } + ) + + +SCOPES = ( + "user_org_default", + "user_only_default", + "organisation_general", + "global_general", +) + + +def _avatar_records(concept_ids): + # Scope is enforced by the same canonical concept visibility mechanism as + # other Von files. Never embed private variants in the public subject. + return list( + ConceptsRepository.find( + { + "attributes.avatar_subject_id": {"$in": concept_ids}, + "attributes.avatar_retired": {"$ne": True}, + }, + sort=[("created_at", -1)], + ) + ) + + +def _resolve_avatar(records, concept_id): + actor = get_effective_user_concept_id() + org = get_effective_organisation_concept_id() + candidates = [] + for record in records: + attrs = record.get("attributes") or {} + if attrs.get("avatar_subject_id") != concept_id: + continue + scope = attrs.get("avatar_scope") + if scope not in SCOPES: + continue + if ( + scope in ("organisation_general", "user_org_default") + and attrs.get("organisation_concept_id") != org + ): + continue + if scope == "user_only_default" and attrs.get("user_concept_id") != actor: + continue + candidates.append(record) + # Source order breaks ties by newest publication within the selected scope. + return ( + min(candidates, key=lambda r: SCOPES.index(r["attributes"]["avatar_scope"])) + if candidates + else None + ) + + +def get_profiles(concept_ids: list[str]) -> list[dict]: + actor = get_effective_user_concept_id() + if not actor: + raise PermissionError("Authentication required.") + if ( + not isinstance(concept_ids, list) + or len(concept_ids) > 100 + or any(not isinstance(cid, str) for cid in concept_ids) + ): + raise ValueError("Supply at most 100 participant IDs.") + docs = list( + ConceptsRepository.find( + {"concept_id": {"$in": list(dict.fromkeys(concept_ids))}}, + projection={"concept_id": 1, "name": 1, "names": 1, "relationships": 1}, + ) + ) + from .concept_service import resolve_concept_display_names + + names = resolve_concept_display_names(docs) + records = _avatar_records([doc["concept_id"] for doc in docs]) + result = [] + for doc in docs: + cid = doc["concept_id"] + record = _resolve_avatar(records, cid) + avatar = (record or {}).get("attributes") or {} + result.append( + { + "concept_id": cid, + "display_name": names.get(cid) + or cid.removeprefix("#V#").replace("_", " "), + "can_edit": actor == cid and _can_edit_profile(actor), + "avatar_scope": avatar.get("avatar_scope"), + "avatar_url": f"/von/api/participants/{quote(cid, safe='')}/avatar?v={avatar['sha256']}" + if avatar.get("sha256") + else None, + "avatar_version": avatar.get("sha256"), + "available_scopes": list(SCOPES) + if get_effective_organisation_concept_id() + else ["user_only_default", "global_general"], + } + ) + return result + + +def get_profile(concept_id: str | None = None) -> dict: + _, concept_id, _ = _participant(concept_id) + profiles = get_profiles([concept_id]) + if not profiles: + raise PermissionError("Participant unavailable.") + return profiles[0] + + +def set_avatar( + *, + concept_id: str | None = None, + image_concept_id: str | None = None, + data: bytes | None = None, + remove: bool = False, + scope: str = "global_general", +) -> dict: + actor, concept_id, _ = _participant(concept_id, edit=True) + from .concept_service import update_concept + + org = get_effective_organisation_concept_id() + if scope not in SCOPES or ( + scope in ("user_org_default", "organisation_general") and not org + ): + raise ValueError("Choose an available avatar scope.") + previous = [ + r + for r in _avatar_records([concept_id]) + if (r.get("attributes") or {}).get("avatar_scope") == scope + and ( + scope not in ("user_org_default", "organisation_general") + or r["attributes"].get("organisation_concept_id") == org + ) + ] + if remove: + for record in previous: + update_concept(record["concept_id"], {"attributes.avatar_retired": True}) + return get_profile(concept_id) + if image_concept_id: + _, data = load_image(image_concept_id, actor) + if not data: + raise ValueError("Choose an image first.") + inspect_image(data) + with Image.open(io.BytesIO(data)) as original: + oriented = ImageOps.exif_transpose(original) + # Reconstruct pixels so EXIF, comments and private source metadata cannot + # be carried into the published derivative. + resized = ImageOps.fit( + oriented.convert("RGBA"), (256, 256), Image.Resampling.LANCZOS + ) + clean = Image.new("RGBA", resized.size) + clean.paste(resized) + output = io.BytesIO() + clean.save(output, format="PNG") + published = output.getvalue() + sha = hashlib.sha256(published).hexdigest() + from .blob_uploads import put_bytes_durable + + stored = put_bytes_durable( + key=f"avatars/{hashlib.sha256(concept_id.encode()).hexdigest()[:24]}/{sha}.png", + data=published, + content_type="image/png", + ) + from .computer_file_copy_service import create_computer_file_copy_instance + + record = create_computer_file_copy_instance( + user_concept_id=actor, + organisation_concept_id=org, + name="Participant avatar.png", + sha256=sha, + size_bytes=len(published), + content_type="image/png", + blob_backend=stored.ref.backend, + blob_key=stored.ref.key, + blob_uri=stored.ref.uri, + visibility_scope_mode=scope, + metadata_in_attributes=True, + maintain_relationship_inverses=False, + metadata={"avatar_subject_id": concept_id, "avatar_scope": scope}, + ) + persisted = ConceptsRepository.find_one({"concept_id": record.concept_id}) + if not persisted or (persisted.get("attributes") or {}).get("sha256") != sha: + raise ValueError("The avatar could not be verified after saving.") + for old in previous: + update_concept(old["concept_id"], {"attributes.avatar_retired": True}) + return { + **get_profile(concept_id), + "saved_scope": scope, + "saved_avatar_version": sha, + } + + +def load_avatar(concept_id: str) -> bytes: + _participant(concept_id) + record = _resolve_avatar(_avatar_records([concept_id]), concept_id) + avatar = (record or {}).get("attributes") or {} + if not avatar.get("blob_key"): + raise PermissionError("Avatar unavailable.") + from .blob_store import get_blob_store_from_env + + data = get_blob_store_from_env().get_bytes(avatar["blob_key"]) + if hashlib.sha256(data).hexdigest() != avatar.get("sha256"): + raise ValueError("Avatar checksum mismatch.") + return data + + +def generate_avatar(*, prompt: str, concept_id: str | None = None) -> dict: + """Generate a private candidate; publishing is a separate, reusable action.""" + actor, _, _ = _participant(concept_id, edit=True) + if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 4000: + raise ValueError("Describe the image in 1–4000 characters.") + from ..languagemodels.openai_client import OpenAIClient + + # Bounded single image; no automatic retry that could duplicate provider cost. + result = ( + OpenAIClient() + .client.with_options(max_retries=0) + .images.generate( + model="gpt-image-1.5", + prompt=prompt.strip(), + n=1, + size="1024x1024", + quality="low", + output_format="png", + ) + ) + if not result.data or not result.data[0].b64_json: + raise ValueError("The image provider returned no image.") + data = base64.b64decode(result.data[0].b64_json, validate=True) + return store_image( + data=data, + filename="generated-avatar.png", + user_concept_id=actor, + provenance={ + "kind": "generated", + "model": "gpt-image-1.5", + "prompt": prompt.strip(), + }, + ) diff --git a/src/frontend/web/von_interface/static/css/participantProfile.css b/src/frontend/web/von_interface/static/css/participantProfile.css new file mode 100644 index 000000000..ee1d8361b --- /dev/null +++ b/src/frontend/web/von_interface/static/css/participantProfile.css @@ -0,0 +1,57 @@ +.participant-avatar { display: inline-grid; place-items: center; position: relative; flex: 0 0 32px; width: 32px; height: 32px; border-radius: 50%; background: #e5edf5; color: #29445d; font-size: 15px; font-weight: 600; overflow: hidden; } +.participant-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.participant-profile-dialog { color: var(--text-color, #243449); background: var(--background-color, white); width: min(480px, calc(100vw - 48px)); max-height: calc(100vh - 48px); border: 1px solid #aebdca; border-radius: 14px; padding: 24px; overflow: auto; box-shadow: 0 12px 60px #0004; } +.participant-profile-dialog::backdrop { background: #192a3d66; } +.participant-profile-dialog h2 { margin-top: 0; } +.participant-profile-dialog button { padding: 8px 12px; margin: 5px; border: 1px solid #aebdca; border-radius: 7px; cursor: pointer; } +.participant-profile-dialog textarea { display: block; box-sizing: border-box; width: 100%; min-height: 80px; margin-top: 12px; padding: 8px; } +.participant-avatar-preview { display: block; width: 160px; height: 160px; object-fit: cover; border-radius: 50%; margin: 12px auto; } +.participant-avatar-preview[hidden] { display: none; } +.participant-profile-dialog > div > .participant-avatar { width: 64px; height: 64px; vertical-align: middle; } +.unified-message-content { display: none; min-width: 0; min-height: 0; flex: 1; } +.show-message-exchange > .chat-conversation-main { display: none; } +.show-message-exchange > .unified-message-content { display: flex; } +.unified-message-content .messages-layout { width: 100%; min-height: 0; height: 100%; } +.unified-message-content .messages-sidebar { display: none; } +.unified-message-content .messages-main { flex: 1; min-width: 0; min-height: 0; } +.unified-message-content .message-list { max-width: 900px; width: 100%; margin-inline: auto; } +.unified-message-content .message-bubble { max-width: 100%; width: auto; align-self: stretch; border-radius: 10px; } +.unified-message-content .message-content { white-space: normal; overflow-wrap: anywhere; } +.unified-message-content .message-content pre { overflow: auto; white-space: pre; max-width: 100%; } +.unified-message-content .message-author { font-weight: 600; margin-bottom: 8px; } +.message-reply-destination { margin: 0 0 6px; font-size: 12px; opacity: .8; } +.message-jump-latest { position: sticky; bottom: 8px; display: block; margin: auto; } +.message-conversation-row .chat-session-tab-header { display: flex; align-items: center; gap: 8px; } +.conversation-source-status { font-size: 12px; padding: 8px; } +@media (max-width: 800px) { .unified-message-content .message-view-header { flex-wrap: wrap; } .unified-message-content .message-bubble { padding: 12px; } } +.unified-message-content .messages-layout { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); border: 0; box-shadow: none; } +.unified-message-content .messages-main { width: 100%; } +.unified-message-content .message-view-header { gap: 8px; } +.unified-message-content .conversation-title { flex: 1; min-width: 120px; } +.unified-message-content .exchange-profile-button { background: transparent; color: inherit; border: 1px solid #ccd7e0; border-radius: 6px; font-size: 12px; padding: 6px 8px; } +.unified-message-content .message-compose-area { flex-wrap: nowrap; } +.unified-message-content .message-reply-destination { flex: none; width: 100%; } +.catalogue-filters { display: flex; gap: 4px; padding: 4px 8px 8px; } +.catalogue-filters input { min-width: 0; width: 100%; padding: 6px; font-size: 12px; border: 1px solid #cdd7de; border-radius: 5px; } +.catalogue-filters button { font-size: 12px; border: 1px solid #cdd7de; border-radius: 5px; padding: 4px; background: white; color: #243449; } +.catalogue-filters button[aria-pressed=true] { background: #dceafa; } +.viewing-message-exchange .conversation-model-controls, .viewing-message-exchange .conversation-model-controls + .footer-separator, .viewing-message-exchange #conversationRuntimeCostInfo { display: none; } +.conversation-unread-badge { background: #466eab; color: white; border-radius: 10px; padding: 1px 6px; font-size: 11px; } +.conversation-unread-badge[hidden] { display: none; } +.chat-session-tab-header > .participant-avatar { margin-right: 5px; vertical-align: middle; } +.catalogue-filters { flex-wrap: wrap; } +.catalogue-filters input { flex-basis: 100%; } +.catalogue-filters button[aria-pressed=true] { background: #dceafa; } + +.message-author { display: flex; align-items: center; gap: 8px; } +.message-author .participant-avatar { width: 28px; height: 28px; font-size: 12px; } + +/* The message adapter uses its own scroll pane inside the common work surface. */ +body.viewing-message-exchange { padding-bottom: 0; } +.viewing-message-exchange .tab-content-area { padding-bottom: 0; } +.viewing-message-exchange .tab-content-area #chatTab.active > .content-wrapper { min-height: 0; } +.conversation-workspace[data-effective-tabs-layout="vertical"] > .unified-message-content { height: calc(var(--von-tab-content-available-height, 80vh) - 20px); } +.conversation-workspace[data-effective-tabs-layout="horizontal"] > .unified-message-content { height: max(380px, calc(var(--von-tab-content-available-height, 80vh) - 150px)); min-height: 0; } +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .catalogue-filters { display: none; } +.viewing-message-exchange .conversation-runtime-cost-button[data-cost-scope="conversation"], +.viewing-message-exchange #conversationRuntimeCostDetails { display: none; } diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index a528081c9..8b8de6119 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -1,4 +1,7 @@ import { createChatSteeringControls } from './components/chatSteeringControls.js'; +import { directConversationRows, activeMessageConversationId, showChatConversation, resetConversationCatalogue, renderMessageConversationRow, mountCatalogueControls, initialiseConversationCatalogue, selectMessageConversation, filterCatalogueRows } from './components/conversationCatalogue.js'; +import { catalogueHasMore } from './components/conversationCatalogue.js'; +import { profileButton, participantAvatar, participantIdentityAvatar } from './components/participantProfile.js'; import { setButtonLabel } from './utils/buttonLabel.js'; import { createConversationTray } from './components/conversationTray.js'; import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_NARROW_QUERY, loadConversationLayoutPreferences, normaliseConversationLayout, saveConversationLayoutPreference } from './utils/conversationLayoutPreferences.js'; @@ -1546,13 +1549,15 @@ function reconcileServerConversationPreferences(sessions) { } async function persistConversationPreferenceAction(sessionId, action) { + const sid = (typeof sessionId === 'string') ? sessionId.trim() : ''; if (!sid) return false; try { - const response = await fetch('/von/api/session/conversation_preference', { + const exchange = directConversationRows().find(row => row.session_id === sid); + const response = await fetch(exchange ? '/api/messages/exchange/preference' : '/von/api/session/conversation_preference', { method: 'POST', headers: buildChatFetchHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ session_id: sid, action }) + body: JSON.stringify(exchange ? { participant_ids: exchange.participant_ids, organisation_concept_id: exchange.organisation_concept_id || null, action } : { session_id: sid, action }) }); const data = await response.json(); if (!response.ok) { @@ -27314,12 +27319,19 @@ function renderChatSessionTabs(sessions, activeSessionId) { if (!container) { return; } + if (container.querySelector(':active') || document.querySelector('dialog[open]')) return; const historyControls = document.getElementById('chatSessionHistoryControls'); if (historyControls) historyControls.innerHTML = ''; const canonicalSessions = normaliseConversationSessionViewModels(sessions); - if (canonicalSessions.length === 0) { + const catalogueSessions = filterCatalogueRows([...canonicalSessions, ...directConversationRows()]); + activeSessionId = activeMessageConversationId() || activeSessionId; + const unreadBadge = document.getElementById('conversationUnreadBadge'); + const unreadCount = [...canonicalSessions, ...directConversationRows()].filter(row => !isConversationHidden(row.session_id) && row.shared_unread_count > 0).length; + if (unreadBadge) { unreadBadge.hidden = !unreadCount; unreadBadge.textContent = `${unreadCount}${catalogueHasMore() ? '+' : ''}`; unreadBadge.setAttribute('aria-label', `${catalogueHasMore() ? 'At least ' : ''}${unreadCount} unread conversations`); } + if (canonicalSessions.length === 0 && directConversationRows().length === 0) { renderChatSessionTabsPlaceholder('empty'); + mountCatalogueControls(container); lastRenderedSessionCount = 0; setChatSessionCount(0); sessionTabsCache = []; @@ -27337,8 +27349,8 @@ function renderChatSessionTabs(sessions, activeSessionId) { // JVNAUTOSCI-1014: Filter hidden sessions unless showHiddenSessions is enabled. const sessionsAfterHiddenFilter = showHiddenSessions - ? canonicalSessions - : canonicalSessions.filter(s => !isConversationHidden(s?.session_id)); + ? catalogueSessions + : catalogueSessions.filter(s => !isConversationHidden(s?.session_id)); const agentFilterResult = filterAgentCreatedSessionsForDefaultView(sessionsAfterHiddenFilter); const serverAgentState = serverAgentCreatedSessionVisibilityState?.applied === true ? serverAgentCreatedSessionVisibilityState @@ -27465,6 +27477,11 @@ function renderChatSessionTabs(sessions, activeSessionId) { const orderedVisibleSessions = [...pinnedSessions, ...unpinnedSessions]; container.hidden = false; + const focusedId = container.contains(document.activeElement) ? document.activeElement?.closest('[data-session-id]')?.dataset.sessionId : null; + const savedScroll = container.scrollTop; + const anchor = savedScroll > 0 ? Array.from(container.querySelectorAll('[data-session-id]')).find(el => el.offsetTop >= savedScroll) : null; + const anchorId = anchor?.dataset.sessionId; + const anchorOffset = anchor ? anchor.offsetTop - savedScroll : 0; container.innerHTML = ''; lastRenderedSessionCount = orderedVisibleSessions.length; setChatSessionCount(Math.max(filteredResult.totalMatchingCount, orderedVisibleSessions.length)); @@ -27527,7 +27544,7 @@ function renderChatSessionTabs(sessions, activeSessionId) { if (orderedVisibleSessions.length === 0) { const placeholder = document.createElement('div'); placeholder.className = 'chat-session-tabs-placeholder chat-session-tabs-placeholder-filtered'; - placeholder.textContent = `No conversations match the current ${filteredResult.recentWindowDays}-day window. Adjust in Settings > Conversations.`; + placeholder.textContent = 'No conversations match the selected filters and recent window.'; fragment.appendChild(placeholder); } @@ -27537,6 +27554,10 @@ function renderChatSessionTabs(sessions, activeSessionId) { return; } const isPinned = isConversationPinned(sid); + if (session.source_kind === 'message_exchange') { + fragment.append(renderMessageConversationRow(session, { selected: sid === activeSessionId, pinned: isPinned, togglePin: () => toggleConversationPinned(sid), hide: () => hideConversation(sid) })); + return; + } const isFirstPinnedTab = pinnedSessions.length > 0 && index === 0 && isPinned; const isFirstRecentTab = pinnedSessions.length > 0 && unpinnedSessions.length > 0 && index === pinnedSessions.length; const groupLabelText = isFirstPinnedTab @@ -27655,6 +27676,7 @@ function renderChatSessionTabs(sessions, activeSessionId) { const header = document.createElement('span'); header.className = 'chat-session-tab-header'; + header.append(participantAvatar({ display_name: session.external_conversation ? displayName : 'Von', avatar_url: session.external_conversation ? null : '/static/VonImageBig.png' })); const pinToggle = document.createElement('button'); pinToggle.type = 'button'; @@ -27769,7 +27791,7 @@ function renderChatSessionTabs(sessions, activeSessionId) { // The compact rail hides visible title/metadata, but retains their accessible name. tab.dataset.trayInitial = Array.from(displayName || 'Conversation')[0].toLocaleUpperCase(); - tab.setAttribute('aria-label', tab.title); + if (displayName) tab.setAttribute('aria-label', tab.title); tab.setAttribute('data-keep-title', 'true'); tab.appendChild(header); tab.appendChild(meta); @@ -27777,7 +27799,7 @@ function renderChatSessionTabs(sessions, activeSessionId) { tab.appendChild(preview); } tab.addEventListener('click', () => { - if (sid === activeChatSessionId) { + if (sid === activeChatSessionId && !activeMessageConversationId()) { return; } void switchToChatSession(sid); @@ -27855,7 +27877,11 @@ function renderChatSessionTabs(sessions, activeSessionId) { fragment.appendChild(tab); }); + mountCatalogueControls(fragment); container.appendChild(fragment); + const restoredAnchor = anchorId ? Array.from(container.querySelectorAll('[data-session-id]')).find(el => el.dataset.sessionId === anchorId) : null; + container.scrollTop = restoredAnchor ? restoredAnchor.offsetTop - anchorOffset : savedScroll; + if (focusedId) Array.from(container.querySelectorAll('[data-session-id]')).find(el => el.dataset.sessionId === focusedId)?.focus({ preventScroll: true }); // JVNAUTOSCI-1014: Context menu on container for showing/hiding hidden sessions // Remove any existing listener to avoid duplicates @@ -28769,6 +28795,7 @@ async function renameChatSession(sessionId, sessionName) { } async function switchToChatSession(sessionId, options = {}) { + showChatConversation(); stopDictation(); const sid = String(sessionId || '').trim(); if (!sid) { @@ -29882,6 +29909,8 @@ function rehydrateHistory(scrollableField, historyMessages, options = {}) { } const historyMessageOptions = { + authorConceptId: msg.author_user_id || null, + contributionTimestamp: msg.contribution_timestamp || null, imageAttachments: msg.image_attachments, ...(externalActor ? { assistantMessage: msg.role === 'assistant', @@ -33637,6 +33666,7 @@ function detachChatWorkForOrganisationSwitch() { } function startOrganisationSwitchForChatTab(detail = {}) { + resetConversationCatalogue(); stopDictation(); const requestedSwitchId = normaliseOrganisationSwitchId(detail); if (requestedSwitchId && pendingChatOrganisationSwitchId === requestedSwitchId) { @@ -33829,6 +33859,7 @@ try { } function handleAuthStatusChangeForChatTab(detail) { + resetConversationCatalogue(); synchroniseLlmExecutionContext({ force: true, reason: 'authenticated_actor_changed' }); loadHiddenChatSessionIds(); loadPinnedChatSessionIds(); @@ -33891,6 +33922,14 @@ export function initializeChatTab() { return; } chatTabInitialised = true; + initialiseConversationCatalogue({ render: (fresh) => { if (fresh === true) reconcileServerConversationPreferences(directConversationRows()); renderChatSessionTabs(sessionTabsCache, activeChatSessionId); }, acceptChats: (rows) => { + serverAgentCreatedSessionVisibilityState = null; + const selected = sessionTabsCache.find(row => row.session_id === activeChatSessionId); + const pinned = sessionTabsCache.filter(row => isConversationPinned(row.session_id)); + sessionTabsCache = normaliseConversationSessionViewModels([...new Map([...pinned, ...(selected ? [selected] : []), ...rows.map(row => ({ ...sessionTabsCache.find(previous => previous.session_id === row.session_id), ...row }))].map(row => [row.session_id, row])).values()]); + reconcileServerConversationPreferences(sessionTabsCache); + }, currentChat: () => sessionTabsCache.find(row => row.session_id === activeChatSessionId) }); + document.querySelector('.chat-header-controls')?.append(profileButton()); console.log("Initializing chat tab..."); bindFocusedConversationEvents(); @@ -33912,7 +33951,9 @@ export function initializeChatTab() { if (!conversationSearchSelectionListenerBound) { conversationSearchSelectionListenerBound = true; document.addEventListener('von:open-conversation-search-result', event => { - void openConversationSearchResult(event?.detail?.conversation || {}); + const result = event?.detail?.conversation || {}; + if (result.source_kind === 'message_exchange') { activateTab('chatTab'); void selectMessageConversation(result); } + else void openConversationSearchResult(result); }); document.addEventListener('von:conversation-trash-changed', event => { if (event?.detail?.action === 'restored') { @@ -38326,6 +38367,7 @@ function appendMessage(sender, message, turnId, hasLlmDebug = false, isHistory = messageContainer.style.cssText = 'display: flex; align-items: flex-start; margin-bottom: 15px; padding: 10px; background-color: #f8f9fa; border-radius: 8px; border-left: 4px solid #007bff;'; messageContainer.className = 'message-container'; messageContainer.classList.add('assistant-turn'); + if (options.contributionTimestamp || timestampStr) messageContainer.dataset.contributionTimestamp = options.contributionTimestamp || timestampStr; let assistantAvatar; if (externalActor) { assistantAvatar = document.createElement('span'); @@ -38805,6 +38847,7 @@ function appendMessage(sender, message, turnId, hasLlmDebug = false, isHistory = messageContainer.style.cssText = 'margin-bottom: 15px; padding: 10px; background-color: #fff; border-radius: 8px; border-left: 4px solid #28a745;'; messageContainer.className = 'message-container'; messageContainer.classList.add(sender === 'Error' ? 'error-turn' : 'user-turn'); + if (options.contributionTimestamp || timestampStr) messageContainer.dataset.contributionTimestamp = options.contributionTimestamp || timestampStr; if (sender === 'Error') { messageContainer.style.borderLeftColor = '#dc3545'; @@ -38815,6 +38858,8 @@ function appendMessage(sender, message, turnId, hasLlmDebug = false, isHistory = messageHeader.className = 'message-header'; messageHeader.style.cssText = 'font-weight: bold; margin-bottom: 5px; font-size: 0.9em; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0;'; messageHeader.style.color = sender === 'Error' ? '#dc3545' : '#28a745'; + const authorId = options.authorConceptId || (!isHistory ? getCurrentUserConceptId() : null); + if (sender !== 'Error' && !externalActor && authorId) messageHeader.append(participantIdentityAvatar(authorId, sender)); const headerText = document.createElement('span'); headerText.textContent = `${sender} • ${displayTimestamp}${historySuffix}`; diff --git a/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js b/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js new file mode 100644 index 000000000..d7f4fd13f --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js @@ -0,0 +1,230 @@ +/** Message-source adapter for the existing conversation tray. */ +import { getJson, postJson } from '../apiService.js'; +import { getSessionScopedOrgId } from '../utils/sessionScopedStorage.js'; +import { participantAvatar, profileButton } from './participantProfile.js'; +import { openMessageExchange, refreshOpenMessageExchange, showMessageComposer, resetMessagePanelContext } from './messagePanel.js'; + +let rows = []; +let catalogueRows = []; +let nextCursor = null; +let acceptChatRows = () => {}; +let generation = 0; +let activeId = null; +let refreshPromise = null; +let profiles = new Map(); +let profilesReadAt = 0; +let sourceError = ''; +let more = false; +const sourceLimit = 100; +let filterText = ''; +let unreadOnly = false; +let allContexts = false; +let onChange = () => {}; +let chatReadObserver = null; +let chatReadPending = false; + +export function filterCatalogueRows(input) { + return input.filter(row => (!unreadOnly || row.shared_unread_count > 0) && (!filterText || `${row.session_name || ''} ${(row.participant_ids || []).join(' ')}`.toLocaleLowerCase().includes(filterText))); +} + +export function directConversationRows() { return rows; } +export function catalogueHasMore() { return more; } +export function activeMessageConversationId() { return activeId; } + +export function showChatConversation() { + activeId = null; + document.body.classList.remove('viewing-message-exchange'); + const workspace = document.getElementById('conversationWorkspace'); + workspace?.classList.remove('show-message-exchange'); +} + +export function resetConversationCatalogue() { + generation += 1; + rows = []; + catalogueRows = []; + nextCursor = null; + resetMessagePanelContext(); + profiles.clear(); + refreshPromise = null; + showChatConversation(); +} + +export async function refreshMessageCatalogue() { + if (refreshPromise) return refreshPromise; + const expected = generation; + const org = getSessionScopedOrgId(); + refreshPromise = (async () => { + try { + const data = await getJson(`/api/messages/conversation-catalogue?limit=${sourceLimit}&all_contexts=${allContexts}`); + if (expected !== generation || org !== getSessionScopedOrgId()) return; + const incomingRows = data.conversations || []; + more = data.has_more === true; + if (!nextCursor || catalogueRows.length <= sourceLimit) nextCursor = data.next_cursor; + sourceError = data.coverage_complete === false ? `Could not refresh ${Object.entries(data.coverage || {}).filter(([, ok]) => !ok).map(([name]) => name).join(' and ')}. The other conversations remain available; refresh to retry.` : ''; + const ids = [...new Set(incomingRows.flatMap(row => row.participant_ids || []))]; + // One batch for the visible source page; never an HTTP request per row. + if (Date.now() - profilesReadAt > 60000 || ids.some(id => !profiles.has(id))) { + const wanted = ids.filter(id => !profiles.has(id) || Date.now() - profilesReadAt > 60000).slice(0, 100); + try { + const batch = await postJson('/von/api/participants/profiles', { concept_ids: wanted }); + if (expected !== generation || org !== getSessionScopedOrgId()) return; + (batch.profiles || []).forEach(profile => profiles.set(profile.concept_id, profile)); + profilesReadAt = Date.now(); + } catch (_) { /* Identity fallback must not hide otherwise available conversations. */ } + } + incomingRows.forEach(row => { + if (row.source_kind !== 'message_exchange') return; + row.participant_profiles = row.participant_ids.map(id => profiles.get(id)).filter(Boolean); + row.session_name = row.other_participant_ids.map(id => profiles.get(id)?.display_name || id.replace(/^#V#/, '').replaceAll('_', ' ')).join(', ') || 'Notes to yourself'; + }); + const incomingIds = new Set(incomingRows.map(row => row.session_id)); + const retained = catalogueRows.filter(row => !incomingIds.has(row.session_id) && (row.session_id === activeId || data.coverage?.[row.source_kind === 'message_exchange' ? 'messages' : 'conversations'] === false || catalogueRows.indexOf(row) >= sourceLimit)); + catalogueRows = [...incomingRows, ...retained]; + rows = catalogueRows.filter(row => row.source_kind === 'message_exchange'); + acceptChatRows(catalogueRows.filter(row => row.source_kind !== 'message_exchange')); + } catch (_) { + if (expected === generation) sourceError = 'Conversations could not be refreshed. Showing the last available list.'; + } finally { + if (expected === generation) { + refreshPromise = null; + onChange(true); + } + } + })(); + return refreshPromise; +} + +export async function selectMessageConversation(row) { + if (!rows.some(item => item.session_id === row.session_id)) rows.push(row); + if (!catalogueRows.some(item => item.session_id === row.session_id)) catalogueRows.push(row); + activeId = row.session_id; + document.body.classList.add('viewing-message-exchange'); + document.getElementById('conversationWorkspace')?.classList.add('show-message-exchange'); + onChange(); + await openMessageExchange(row); +} + +export function renderMessageConversationRow(row, { selected = false, pinned = false, togglePin, hide } = {}) { + const el = document.createElement('button'); + el.type = 'button'; + el.className = `chat-session-tab message-conversation-row${selected ? ' is-active' : ''}${row.shared_unread_count ? ' has-unread' : ''}`; + el.dataset.sessionId = row.session_id; + el.dataset.trayInitial = Array.from(row.session_name || 'V')[0]; + el.setAttribute('role', 'tab'); + el.setAttribute('aria-selected', String(selected)); + const identity = profiles.get(row.other_participant_ids[0]) || { display_name: row.session_name }; + const header = document.createElement('span'); + header.className = 'chat-session-tab-header'; + header.append(participantAvatar(identity)); + const name = document.createElement('span'); + name.className = 'chat-session-tab-name'; + name.textContent = row.session_name; + header.append(name); + if (row.shared_unread_count) { + const badge = document.createElement('span'); + badge.className = 'chat-session-tab-unread'; + badge.textContent = String(row.shared_unread_count); + badge.setAttribute('aria-label', `${row.shared_unread_count} unread messages`); + header.append(badge); + } + const meta = document.createElement('span'); + meta.className = 'chat-session-tab-meta'; + meta.textContent = `${pinned ? 'Pinned · ' : ''}${new Date(row.last_message_at).toLocaleString()}`; + const preview = document.createElement('span'); + preview.className = 'chat-session-tab-preview'; + if (allContexts && row.organisation_concept_id) meta.textContent += ` · ${row.organisation_concept_id.replace(/^#V#/, '').replaceAll('_', ' ')}`; + preview.textContent = `${row.last_author_id === row.viewer_id ? 'You' : profiles.get(row.last_author_id)?.display_name || row.session_name}: ${row.preview || ''}`; + el.title = `${row.session_name}\n${preview.textContent}\n${meta.textContent}`; + el.setAttribute('aria-label', el.title); + el.append(header, meta, preview); + el.addEventListener('click', () => void selectMessageConversation(row)); + el.addEventListener('contextmenu', event => { + event.preventDefault(); + const menu = document.createElement('dialog'); + menu.className = 'participant-profile-dialog'; + const action = (label, fn) => { + const b = document.createElement('button'); b.type = 'button'; b.textContent = label; + b.onclick = () => { menu.close(); fn(); }; menu.append(b); + }; + action(pinned ? 'Unpin conversation' : 'Pin conversation', togglePin); + action('Hide conversation', hide); + row.participant_ids.forEach(id => menu.append(profileButton(id, profiles.get(id)?.display_name || id))); + action('Close', () => {}); + document.body.append(menu); menu.addEventListener('close', () => menu.remove()); menu.showModal(); + }); + return el; +} + +export function mountCatalogueControls(container) { + const compose = document.createElement('button'); + compose.type = 'button'; compose.className = 'chat-session-tab'; + compose.textContent = 'Talk to a person or agent'; + compose.onclick = () => { void showMessageComposer(); }; + container.append(compose); + if (more && nextCursor) { + const moreButton = document.createElement('button'); moreButton.type = 'button'; moreButton.textContent = 'Load older conversations'; + moreButton.onclick = async () => { + const expected = generation; + moreButton.disabled = true; + try { + const page = await getJson(`/api/messages/conversation-catalogue?limit=${sourceLimit}&cursor=${encodeURIComponent(nextCursor)}&all_contexts=${allContexts}`); + if (expected !== generation) return; + catalogueRows = [...new Map([...catalogueRows, ...(page.conversations || [])].map(row => [row.session_id, row])).values()]; + rows = catalogueRows.filter(row => row.source_kind === 'message_exchange'); + acceptChatRows(catalogueRows.filter(row => row.source_kind !== 'message_exchange')); + nextCursor = page.next_cursor; + more = page.has_more; onChange(true); + } catch (_) { moreButton.textContent = 'Could not load older conversations. Retry'; moreButton.disabled = false; } + }; + container.append(moreButton); + } + if (sourceError) { + const status = document.createElement('div'); + status.className = 'conversation-source-status'; status.setAttribute('role', 'status'); + status.textContent = sourceError; + container.append(status); + } +} + +export function initialiseConversationCatalogue({ render, acceptChats, currentChat }) { + onChange = render; + acceptChatRows = acceptChats || (() => {}); + const controls = document.createElement('div'); controls.className = 'catalogue-filters'; + const filter = document.createElement('input'); filter.type = 'search'; filter.placeholder = 'Filter title or participant'; filter.setAttribute('aria-label', 'Filter listed conversations by title or participant'); + filter.oninput = () => { filterText = filter.value.trim().toLocaleLowerCase(); render(); }; + const unread = document.createElement('button'); unread.type = 'button'; unread.textContent = 'Unread'; unread.setAttribute('aria-pressed', 'false'); + unread.onclick = () => { unreadOnly = !unreadOnly; unread.setAttribute('aria-pressed', String(unreadOnly)); render(); }; + const contexts = document.createElement('button'); contexts.type = 'button'; contexts.textContent = 'All organisations'; contexts.title = 'Include your message exchanges from other organisations'; contexts.setAttribute('aria-pressed', 'false'); + contexts.onclick = () => { allContexts = !allContexts; contexts.setAttribute('aria-pressed', String(allContexts)); resetConversationCatalogue(); void refreshMessageCatalogue(); }; + controls.append(filter, unread, contexts); + document.querySelector('.conversation-tray-header')?.after(controls); + const refresh = async () => { + if (document.visibilityState === 'hidden') return; + await Promise.allSettled([refreshMessageCatalogue(), refreshOpenMessageExchange()]); + chatReadObserver?.disconnect(); + const row = currentChat?.(); + if (!activeId && row?.last_incoming_timestamp && row?.shared_unread_count && typeof IntersectionObserver === 'function') { + const observed = row.last_incoming_timestamp; + const expected = generation; + chatReadObserver = new IntersectionObserver(entries => { + if (chatReadPending || document.hidden || activeId || expected !== generation || currentChat?.()?.session_id !== row.session_id) return; + if (!entries.some(entry => entry.isIntersecting && entry.target.getClientRects().length)) return; + chatReadPending = true; + void postJson('/von/history/read', { session_id: row.session_id, observed_timestamp: observed, observed_turn_id: row.last_incoming_turn_id || null }).then(() => { + if (generation === expected) { row.shared_unread_count = 0; onChange(); } + }).catch(() => {}).finally(() => { chatReadPending = false; }); + }, { threshold: 0.01 }); + document.querySelectorAll('.message-container').forEach(el => { + if ((row.last_incoming_turn_id && el.dataset.turnId === row.last_incoming_turn_id) || new Date(el.dataset.contributionTimestamp).getTime() === new Date(observed).getTime()) chatReadObserver.observe(el); + }); + } + }; + // Polling is independent of the selected exchange and backend worker process. + // Schedule after completion so slow networks cannot accumulate requests. + const poll = async () => { const started = Date.now(); await refresh(); setTimeout(poll, Math.max(500, 4000 - (Date.now() - started))); }; + void poll(); + window.addEventListener('focus', () => void refresh()); + document.addEventListener('visibilitychange', () => { if (!document.hidden) void refresh(); }); + document.addEventListener('von:conversation-contribution', () => void refresh()); + document.addEventListener('von:participant-profile-changed', () => { profilesReadAt = 0; void refreshMessageCatalogue(); }); +} diff --git a/src/frontend/web/von_interface/static/js/components/messagePanel.js b/src/frontend/web/von_interface/static/js/components/messagePanel.js index 113aadd83..4d59413c0 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -1,3 +1,6 @@ +import { initializeConceptAutocomplete } from './conceptAutocomplete.js'; +import { simpleMarkdownToHtml } from '../markdownUtils.js'; +import { profileButton, participantAvatar } from './participantProfile.js'; /** * Message Panel Component (JVNAUTOSCI-1071) * @@ -8,7 +11,7 @@ import { getJson, postJson, postJsonDetailed } from '../apiService.js'; import { getSessionScopedOrgId } from '../utils/sessionScopedStorage.js'; import { hydrateConceptCartouchesInRoot } from '../utils/selectConceptByIdHandler.js'; -import { cartouchifyElementText } from '../utils/textDecorator.js'; +import { createCartoucheFragment } from '../utils/textDecorator.js'; import { showToast } from '../utils/toast.js'; import { buildMessageStreamReference } from '../utils/messageStreamReference.js'; import { copyTextWithClipboardFallback } from '../utils/copyJsonButtonState.js'; @@ -34,6 +37,108 @@ let _replyDeliveryAttempt = null; let _newMessageDeliveryAttempt = null; let _deliveryKeySequence = 0; +let _exchange = null; +let _exchangeGeneration = 0; +let _olderCursor = null; +let _readObserver = null; +const _exchangeDrafts = new Map(); + +function exchangeScope(row = _exchange) { + return row ? `${row.browser_scope ?? getSessionScopedOrgId() ?? ''}:${row.viewer_id}:${row.session_id}` : ''; +} + +export function resetMessagePanelContext() { + const input = _messagesContainer?.querySelector('#messageInput'); + if (_exchange && input) _exchangeDrafts.set(exchangeScope(), input.value); + _exchangeGeneration += 1; + _exchange = null; + _currentConversationUserId = null; + _currentMessages = []; + _isLoading = false; + _readObserver?.disconnect(); + _messagesContainer?.replaceChildren(); +} + +export async function openMessageExchange(row) { + const oldInput = _messagesContainer?.querySelector('#messageInput'); + if (_exchange && oldInput) _exchangeDrafts.set(exchangeScope(), oldInput.value); + _exchangeGeneration += 1; + _isLoading = false; + _readObserver?.disconnect(); + _exchange = { ...row, browser_scope: getSessionScopedOrgId() }; + resetMessagesViewportPosition(); + _olderCursor = null; + if (!_messagesContainer) initializeMessagePanel(); + if (!_messagesContainer?.querySelector('#messageViewContent')) renderMessagesTabContent(); + _currentUserId = row.viewer_id; + const selectedGeneration = _exchangeGeneration; + await selectConversation(row.other_participant_ids[0] || row.viewer_id); + if (selectedGeneration !== _exchangeGeneration) return; + const title = _messagesContainer?.querySelector('#conversationTitle'); + if (title) title.textContent = row.session_name; + const header = _messagesContainer?.querySelector('#messageViewHeader'); + header?.querySelectorAll('.exchange-profile-button').forEach(el => el.remove()); + for (const id of row.participant_ids) { + const b = profileButton(id, id === row.viewer_id ? 'Your profile' : 'Participant profile'); + b.className = 'exchange-profile-button'; header?.append(b); + } + const input = _messagesContainer?.querySelector('#messageInput'); + if (input) { + input.value = _exchangeDrafts.get(exchangeScope()) || ''; + input.placeholder = `Reply to ${row.session_name}`; + } + const compose = _messagesContainer?.querySelector('#messageComposeArea'); + let destination = compose?.querySelector('.message-reply-destination'); + if (compose && !destination) { destination = document.createElement('p'); destination.className = 'message-reply-destination'; compose.prepend(destination); } + if (destination) destination.textContent = `To: ${row.session_name} · ${row.organisation_concept_id?.replace(/^#V#/, '').replaceAll('_', ' ') || 'Original conversation context'}`; + // A legacy group exchange can be read without silently dropping recipients. + if (row.other_participant_ids.length > 1) { + if (destination) destination.textContent += ' · Group replies are not yet supported.'; + if (input) input.disabled = true; + const send = compose?.querySelector('#sendMessageBtn'); if (send) send.disabled = true; + } else { + if (input) input.disabled = false; + const send = compose?.querySelector('#sendMessageBtn'); if (send) send.disabled = false; + } +} + +let composerOpenedFromChat = false; + +export async function showMessageComposer() { + if (!_messagesContainer) initializeMessagePanel(); + if (!_messagesContainer?.querySelector('#newMessageModal')) renderMessagesTabContent(); + const workspace = document.getElementById('conversationWorkspace'); + composerOpenedFromChat = !workspace?.classList.contains('show-message-exchange'); + workspace?.classList.add('show-message-exchange'); + document.body.classList.add('viewing-message-exchange'); + showNewMessageModal(); +} + +export async function refreshOpenMessageExchange() { + if (!_exchange || document.hidden || !document.getElementById('conversationWorkspace')?.classList.contains('show-message-exchange')) return; + await loadConversation(_currentConversationUserId, { silent: true }); +} + +function observeDisplayedMessages() { + _readObserver?.disconnect(); + if (typeof IntersectionObserver !== 'function') return; + const root = _messagesContainer?.querySelector('#messageViewContent'); + _readObserver = new IntersectionObserver(entries => { + if (document.hidden || !root?.getClientRects().length) return; + const ids = entries.filter(entry => entry.isIntersecting && entry.intersectionRatio > 0) + .map(entry => entry.target.dataset.contributionId) + .filter(id => _currentMessages.some(m => m.concept_id === id && (m.relationships?.['#V#has_recipient'] || []).includes(_currentUserId) && !(m.concept_data?.read_by || []).includes(_currentUserId))); + if (!ids.length) return; + const generation = _exchangeGeneration; + void postJson('/api/messages/read/bulk', { message_ids: ids }).then(() => { + if (generation !== _exchangeGeneration) return; + _currentMessages.forEach(m => { if (ids.includes(m.concept_id)) { m.concept_data ||= {}; m.concept_data.read_by = [...new Set([...(m.concept_data.read_by || []), _currentUserId])]; } }); + document.dispatchEvent(new CustomEvent('von:conversation-contribution')); + }).catch(() => {}); + }, { root, threshold: 0.01 }); + root?.querySelectorAll('[data-contribution-id]').forEach(el => _readObserver.observe(el)); +} + const COMPOSE_SCOPE_REPLY = 'reply'; const COMPOSE_SCOPE_NEW_MESSAGE = 'newMessage'; const REPLY_DELIVERY_ATTEMPT_STORAGE_KEY = 'von_message_reply_delivery_attempt_v1'; @@ -176,7 +281,7 @@ function renderMessagesTabContent() {
+ placeholder="Type #V# then a person or agent name"> @@ -200,6 +305,7 @@ function renderMessagesTabContent() { // Attach event listeners attachMessagesEventListeners(); + initializeConceptAutocomplete(_messagesContainer.querySelector('#newMessageRecipient')); restoreNewMessageDeliveryAttemptForCurrentScope(); renderComposePendingUi(COMPOSE_SCOPE_REPLY); renderComposePendingUi(COMPOSE_SCOPE_NEW_MESSAGE); @@ -491,7 +597,7 @@ function bindMessageStreamMenu(element, getOtherUserId) { if (!otherUserId || !userId) return; const thread = _threads.find(row => getThreadUserId(row) === otherUserId); const messages = otherUserId === _currentConversationUserId ? _currentMessages : [thread?.last_message].filter(Boolean); - const payload = buildMessageStreamReference({ currentUserId: userId, otherUserId, messages, displayName: `Conversation with ${formatUserName(otherUserId)}` }); + const payload = buildMessageStreamReference({ currentUserId: userId, otherUserId, messages, displayName: _exchange?.session_name || `Conversation with ${formatUserName(otherUserId)}`, participantIds: _exchange?.participant_ids, organisationConceptId: _exchange?.organisation_concept_id }); const { openChatSessionMenu } = await import('../chatTab.js'); if (!element.isConnected || _currentUserId !== userId) return; const rect = element.getBoundingClientRect(); @@ -515,53 +621,56 @@ function bindMessageStreamMenu(element, getOtherUserId) { /** * Load messages for a conversation with a specific user. */ -async function loadConversation(userId) { +async function loadConversation(userId, { silent = false, before = null } = {}) { if (_isLoading) return; - _isLoading = true; + const generation = _exchangeGeneration; + const org = getSessionScopedOrgId(); const contentEl = _messagesContainer?.querySelector('#messageViewContent'); - - if (contentEl) { - contentEl.innerHTML = '
Loading messages...
'; - } - + const oldTop = contentEl?.scrollTop || 0; + const oldHeight = contentEl?.scrollHeight || 0; + const nearBottom = !contentEl || oldHeight - oldTop - contentEl.clientHeight < 60; + if (contentEl && !silent) contentEl.innerHTML = '
Loading conversation…
'; try { - const response = await getJson(`/api/messages/conversation/${encodeURIComponent(userId)}?limit=50`); - _currentMessages = response.messages || []; + const response = _exchange + ? await postJson('/api/messages/exchange', { participant_ids: _exchange.participant_ids, organisation_concept_id: _exchange.organisation_concept_id || null, before }) + : await getJson(`/api/messages/conversation/${encodeURIComponent(userId)}?limit=50`); + if (generation !== _exchangeGeneration || org !== getSessionScopedOrgId() || userId !== _currentConversationUserId) return; + const incoming = response.messages || []; + const compare = (a, b) => new Date(a.created_at) - new Date(b.created_at) || String(a.concept_id).localeCompare(String(b.concept_id)); + let nextMessages = incoming; + if (before) nextMessages = [...new Map([..._currentMessages, ...incoming].map(m => [m.concept_id, m])).values()].sort(compare); + else if (silent && incoming.length) { + // Reconcile the returned range, including deletions and edits, while + // retaining already loaded earlier pages. A reconnect gap remains pageable. + const overlap = incoming.some(m => _currentMessages.some(old => old.concept_id === m.concept_id)); + if (!overlap && response.before) _olderCursor = response.before; + nextMessages = [..._currentMessages.filter(m => compare(m, incoming[0]) < 0), ...incoming]; + } + const sameContent = JSON.stringify(nextMessages.map(m => [m.concept_id, m.concept_data?.content_fallback])) === JSON.stringify(_currentMessages.map(m => [m.concept_id, m.concept_data?.content_fallback])); + _currentMessages = nextMessages; + if (silent && !before && sameContent) return; + if (before || !silent) _olderCursor = response.before || null; _currentUserId = response.current_user_id || null; await renderMessages(); + if (generation !== _exchangeGeneration || org !== getSessionScopedOrgId()) return; restoreReplyDeliveryAttemptForCurrentScope(); - - // Mark messages as read - const unreadIds = _currentMessages - .filter(m => { - const readBy = m.concept_data?.read_by || []; - const recipientIds = m.relationships?.['#V#has_recipient'] || []; - return Boolean( - _currentUserId - && recipientIds.includes(_currentUserId) - && !readBy.includes(_currentUserId) - ); - }) - .map(m => m.concept_id); - - if (unreadIds.length > 0) { - try { - await postJson('/api/messages/read/bulk', { message_ids: unreadIds }); - await loadUnreadCount(); - } catch (e) { - console.warn('[messagePanel] Failed to mark messages as read:', e); - } + if (_olderCursor && contentEl) { + const earlier = document.createElement('button'); earlier.type = 'button'; earlier.textContent = 'Load earlier messages'; + earlier.onclick = () => void loadConversation(userId, { silent: true, before: _olderCursor }); + contentEl.prepend(earlier); } - - } catch (err) { - console.error('[messagePanel] Failed to load conversation:', err); - if (contentEl) { - contentEl.innerHTML = '
Failed to load messages
'; + if (contentEl && before) contentEl.scrollTop = oldTop + contentEl.scrollHeight - oldHeight; + else if (contentEl && silent && !nearBottom) { + contentEl.scrollTop = oldTop; + const jump = document.createElement('button'); jump.type = 'button'; jump.className = 'message-jump-latest'; jump.textContent = 'New messages · Jump to latest'; + jump.onclick = () => { contentEl.scrollTop = contentEl.scrollHeight; jump.remove(); }; + contentEl.append(jump); } - } finally { - _isLoading = false; - } + observeDisplayedMessages(); + } catch (_) { + if (generation === _exchangeGeneration && contentEl && !silent) contentEl.innerHTML = '
Could not load this conversation. Try Refresh.
'; + } finally { if (generation === _exchangeGeneration) _isLoading = false; } } function isPaperRecommendationMessage(message) { @@ -816,9 +925,10 @@ async function renderMessages() { : senderId !== _currentConversationUserId; html += ` -
+
+
${escapeHtml(formatUserName(senderId))}
${subject ? `
${escapeHtml(subject)}
` : ''} -
${escapeHtml(content)}
+
${simpleMarkdownToHtml(content)}
${isRecommendationMessage ? `
@@ -841,6 +951,7 @@ async function renderMessages() { ` : ''}
${timestamp ? formatTime(timestamp) : ''} + ${msg.concept_id ? `` : ''} @@ -851,11 +962,20 @@ async function renderMessages() { html += '
'; contentEl.innerHTML = html; + contentEl.querySelectorAll('.message-author[data-participant-id]').forEach(author => { + const id = author.dataset.participantId; + const profile = _exchange?.participant_profiles?.find(profile => profile.concept_id === id) || { display_name: formatUserName(id) }; + author.textContent = profile.display_name; + author.prepend(participantAvatar(profile)); + }); const messageContentEls = Array.from(contentEl.querySelectorAll('.message-list .message-content')); - messageContentEls.forEach((messageContentEl, index) => { - const content = _currentMessages[index]?.concept_data?.content_fallback || ''; - cartouchifyElementText(messageContentEl, content); + messageContentEls.forEach((messageContentEl) => { + // Preserve Markdown structure while decorating text nodes. + const walker = document.createTreeWalker(messageContentEl, NodeFilter.SHOW_TEXT); + const nodes = []; + while (walker.nextNode()) if (walker.currentNode.textContent.includes('#V#') && !walker.currentNode.parentElement.closest('code, pre, a, button')) nodes.push(walker.currentNode); + nodes.forEach(node => node.replaceWith(createCartoucheFragment(node.textContent))); }); contentEl.querySelectorAll('[data-message-recommendation-toggle]').forEach((button) => { button.addEventListener('click', () => { @@ -866,6 +986,7 @@ async function renderMessages() { } }); }); + contentEl.querySelectorAll('.message-copy-markdown').forEach(button => { button.onclick = async () => { const message = _currentMessages.find(m => m.concept_id === button.dataset.messageId); if (message) { const copied = await copyTextWithClipboardFallback(message.concept_data?.content_fallback || ''); button.textContent = copied ? 'Copied' : 'Copy failed'; } }; }); contentEl.querySelectorAll('.message-discuss-btn').forEach((button) => { button.addEventListener('click', (event) => { event.preventDefault(); @@ -906,7 +1027,7 @@ function renderComposePendingUi(scope) { if (!button) return; const pending = isComposePending(scope); - button.disabled = pending; + button.disabled = pending || (scope === COMPOSE_SCOPE_REPLY && _exchange?.other_participant_ids?.length > 1); button.textContent = pending ? 'Sending…' : 'Send'; button.setAttribute('aria-busy', pending ? 'true' : 'false'); @@ -1833,7 +1954,7 @@ function buildSendPayload({ ? failureState.organisationOptions : []; const selectedOrganisationConceptId = String( - failureState?.selectedOrganisationConceptId || '', + failureState?.selectedOrganisationConceptId || (scope === COMPOSE_SCOPE_REPLY ? _exchange?.organisation_concept_id : '') || '', ).trim(); if (organisationOptions.length > 0 && !selectedOrganisationConceptId) { @@ -1873,6 +1994,7 @@ async function handleSendReply() { return; } + if (_exchange?.other_participant_ids?.length > 1) return; if (!_currentConversationUserId) { showToast('No conversation selected', 'warning'); return; @@ -1900,9 +2022,11 @@ async function handleSendReply() { } const submittedConversationUserId = _currentConversationUserId; + const submittedGeneration = _exchangeGeneration; setComposePending(COMPOSE_SCOPE_REPLY, true); try { - await postJsonDetailed('/api/messages/', payload); + const result = await postJsonDetailed('/api/messages/', payload); + if (submittedGeneration !== _exchangeGeneration) { document.dispatchEvent(new CustomEvent('von:conversation-contribution')); return; } resetComposeFailureUi(COMPOSE_SCOPE_REPLY); setDeliveryAttempt(COMPOSE_SCOPE_REPLY, null); @@ -1913,14 +2037,20 @@ async function handleSendReply() { ) { activeReplyInput.value = ''; } + _exchangeDrafts.delete(exchangeScope()); + document.dispatchEvent(new CustomEvent('von:conversation-contribution')); showToast('Message sent', 'success'); - // Reload conversation - await loadConversation(_currentConversationUserId); + if (_exchange && result.data?.conversation && result.data.conversation.session_id !== _exchange.session_id) { + // An explicit organisation recovery starts its own exchange; it + // must not rewrite the identity of the earlier conversation. + const { selectMessageConversation } = await import('./conversationCatalogue.js'); + await selectMessageConversation(result.data.conversation); + } else await loadConversation(_currentConversationUserId); } catch (err) { console.error('[messagePanel] Failed to send message:', err); - recordComposeSendFailure(COMPOSE_SCOPE_REPLY, err); + if (submittedGeneration === _exchangeGeneration) recordComposeSendFailure(COMPOSE_SCOPE_REPLY, err); } finally { setComposePending(COMPOSE_SCOPE_REPLY, false); } @@ -1949,6 +2079,12 @@ function hideNewMessageModal({ force = false } = {}) { return; } + if (composerOpenedFromChat) { + document.getElementById('conversationWorkspace')?.classList.remove('show-message-exchange'); + document.body.classList.remove('viewing-message-exchange'); + composerOpenedFromChat = false; + } + const modal = _messagesContainer?.querySelector('#newMessageModal'); if (modal) { modal.classList.add('hidden'); @@ -2007,22 +2143,31 @@ async function handleSendNewMessage() { return; } + const submittedGeneration = _exchangeGeneration; setComposePending(COMPOSE_SCOPE_NEW_MESSAGE, true); try { - await postJsonDetailed('/api/messages/', payload); + const result = await postJsonDetailed('/api/messages/', payload); + if (submittedGeneration !== _exchangeGeneration) { document.dispatchEvent(new CustomEvent('von:conversation-contribution')); return; } resetComposeFailureUi(COMPOSE_SCOPE_NEW_MESSAGE); setDeliveryAttempt(COMPOSE_SCOPE_NEW_MESSAGE, null); + document.dispatchEvent(new CustomEvent('von:conversation-contribution')); showToast('Message sent', 'success'); hideNewMessageModal({ force: true }); - // Reload threads and select the new conversation - await loadMessageThreads(); - await selectConversation(recipientId); + if (result.data?.conversation) { + const { selectMessageConversation } = await import('./conversationCatalogue.js'); + await selectMessageConversation(result.data.conversation); + } else { + // Compatibility with an older server response. + _exchange = null; + await loadMessageThreads(); + await selectConversation(recipientId); + } } catch (err) { console.error('[messagePanel] Failed to send new message:', err); - recordComposeSendFailure(COMPOSE_SCOPE_NEW_MESSAGE, err); + if (submittedGeneration === _exchangeGeneration) recordComposeSendFailure(COMPOSE_SCOPE_NEW_MESSAGE, err); } finally { setComposePending(COMPOSE_SCOPE_NEW_MESSAGE, false); } diff --git a/src/frontend/web/von_interface/static/js/components/participantProfile.js b/src/frontend/web/von_interface/static/js/components/participantProfile.js new file mode 100644 index 000000000..c31650879 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/participantProfile.js @@ -0,0 +1,181 @@ +import { getJson, postJson, ensureUniqueWindowSessionId } from '../apiService.js'; +import { getCurrentUserConceptId } from '../domUtils.js'; +import { getSessionScopedOrgId } from '../utils/sessionScopedStorage.js'; + +const identityProfiles = new Map(); +document.addEventListener('von:participant-profile-changed', event => { + identityProfiles.clear(); + document.querySelectorAll('.participant-avatar[data-participant-id]').forEach(el => { + if (el.dataset.participantId === event.detail?.concept_id) { + el.replaceWith(participantIdentityAvatar(el.dataset.participantId, event.detail.display_name || el.dataset.displayName)); + } + }); +}); + +/** A cached identity portrait for contribution headers across conversation kinds. */ +export function participantIdentityAvatar(conceptId, displayName) { + const el = participantAvatar({ display_name: displayName }); + if (!conceptId) return el; + el.dataset.participantId = conceptId; + el.dataset.displayName = displayName || ''; + const context = `${getCurrentUserConceptId()}:${getSessionScopedOrgId()}`; + const key = `${context}:${conceptId}`; + let cached = identityProfiles.get(key); + if (!cached || Date.now() - cached.at > 60000) { + cached = { at: Date.now(), promise: Promise.resolve(getJson(`/von/api/participants/profile?concept_id=${encodeURIComponent(conceptId)}`)) }; + identityProfiles.set(key, cached); + if (identityProfiles.size > 100) identityProfiles.delete(identityProfiles.keys().next().value); + } + void cached.promise.then(({ profile }) => { + if (!el.isConnected || context !== `${getCurrentUserConceptId()}:${getSessionScopedOrgId()}`) return; + el.replaceChildren(...participantAvatar(profile).childNodes); + }).catch(() => {}); + el.title = `${displayName || 'Participant'} profile and avatar`; + el.setAttribute('role', 'button'); el.tabIndex = 0; + el.onclick = () => void openParticipantProfile(conceptId); + el.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); el.click(); } }; + return el; +} + +function button(label, action) { + const el = document.createElement('button'); + el.type = 'button'; + el.textContent = label; + el.addEventListener('click', action); + return el; +} + +export function participantAvatar(profile = {}) { + const el = document.createElement('span'); + el.className = 'participant-avatar'; + el.textContent = Array.from(profile.display_name || 'V')[0].toLocaleUpperCase(); + if (profile.avatar_url) { + const img = document.createElement('img'); + // Image elements cannot carry the normal API window-context header. + // The server validates this window ID against the authenticated actor. + void ensureUniqueWindowSessionId().then(id => { + img.src = `${profile.avatar_url}${profile.avatar_url.includes('?') ? '&' : '?'}window_session_id=${encodeURIComponent(id)}`; + }); + img.alt = ''; + img.addEventListener('error', () => img.remove(), { once: true }); + el.append(img); + } + return el; +} + +export function profileButton(conceptId = null, label = 'Profile and avatar') { + return button(label, () => void openParticipantProfile(conceptId)); +} + +export async function openParticipantProfile(conceptId = null) { + document.getElementById('participantProfileDialog')?.remove(); + const dialog = document.createElement('dialog'); + dialog.id = 'participantProfileDialog'; + dialog.className = 'participant-profile-dialog'; + const title = document.createElement('h2'); + title.id = 'participantProfileTitle'; + title.textContent = 'Participant profile'; + dialog.setAttribute('aria-labelledby', title.id); + const status = document.createElement('p'); + status.setAttribute('role', 'status'); + status.textContent = 'Loading profile…'; + const body = document.createElement('div'); + dialog.append(title, body, status, button('Close', () => dialog.close())); + dialog.addEventListener('close', () => dialog.remove()); + document.body.append(dialog); + dialog.showModal(); + try { + let { profile } = await getJson(`/von/api/participants/profile${conceptId ? `?concept_id=${encodeURIComponent(conceptId)}` : ''}`); + if (!dialog.isConnected) return; + title.textContent = profile.display_name; + let avatar = participantAvatar(profile); + body.append(avatar); + body.append(button('Open concept details', () => { + dialog.close(); + (window.parent || window).document.dispatchEvent(new CustomEvent('von:selectConceptById', { detail: { conceptId: profile.concept_id, createConceptTab: true, kind: profile.can_edit ? 'individual' : null, modifierKeys: { shiftKey: true } } })); + })); + status.textContent = ''; + if (!profile.can_edit) return; + const audience = document.createElement('p'); + audience.textContent = 'Generation makes one image request and may incur a provider charge. The preview stays private until you choose Use avatar. Choose who can see this avatar using its scope. More specific avatars take precedence in the matching context. Uploaded images are centred and cropped to a square.'; + const scope = document.createElement('select'); + scope.setAttribute('aria-label', 'Avatar scope'); + const scopeNames = { user_org_default: 'User and organisation', user_only_default: 'User only', organisation_general: 'Organisation', global_general: 'Global' }; + (profile.available_scopes || Object.keys(scopeNames)).forEach(value => { const option = document.createElement('option'); option.value = value; option.textContent = scopeNames[value]; scope.append(option); }); + scope.value = profile.avatar_scope || 'global_general'; + body.append(audience, scope); + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/png,image/jpeg,image/webp'; + input.setAttribute('aria-label', 'Upload avatar image'); + const preview = document.createElement('img'); + preview.className = 'participant-avatar-preview'; + preview.alt = 'Avatar preview'; + preview.hidden = true; + const prompt = document.createElement('textarea'); + prompt.placeholder = 'Describe an avatar to generate'; + prompt.setAttribute('aria-label', 'Avatar image description'); + prompt.maxLength = 4000; + let candidate = null; + let objectUrl = null; + let pending = false; + const run = async (message, action) => { + if (pending) return; + pending = true; + status.textContent = message; + body.querySelectorAll('button, input, textarea, select').forEach(el => { el.disabled = true; }); + try { await action(); } catch (error) { status.textContent = error.message || 'Could not save the avatar. Try again.'; } + finally { + pending = false; + body.querySelectorAll('button, input, textarea, select').forEach(el => { el.disabled = false; }); + use.disabled = !candidate; + } + }; + input.addEventListener('change', () => { + if (!input.files[0]) return; + if (objectUrl) URL.revokeObjectURL(objectUrl); + objectUrl = URL.createObjectURL(input.files[0]); + candidate = { file: input.files[0] }; + preview.src = objectUrl; + preview.hidden = false; + use.disabled = false; + }); + const generate = button('Generate preview', () => run('Generating an image…', async () => { + const result = await postJson('/von/api/participants/avatar/generate', { concept_id: profile.concept_id, prompt: prompt.value }); + candidate = { image_concept_id: result.image.concept_id }; + preview.src = result.image.url; + preview.hidden = false; + status.textContent = 'Preview ready. Choose Use avatar to publish it.'; + })); + const saved = result => { + identityProfiles.clear(); + profile = result.profile; + const replacement = participantAvatar(profile); + avatar.replaceWith(replacement); + avatar = replacement; + document.dispatchEvent(new CustomEvent('von:participant-profile-changed', { detail: profile })); + if (window.parent !== window) window.parent.document.dispatchEvent(new CustomEvent('von:participant-profile-changed', { detail: profile })); + status.textContent = 'Avatar saved.'; + }; + const use = button('Use avatar', () => run('Saving avatar…', async () => { + if (candidate.file) { + const form = new FormData(); + form.append('file', candidate.file); + form.append('concept_id', profile.concept_id); + form.append('scope', scope.value); + const response = await fetch('/von/api/participants/avatar', { method: 'POST', headers: { 'X-Von-Window-Session': await ensureUniqueWindowSessionId() }, body: form }); + const result = await response.json(); + if (!response.ok) throw new Error(result.message || 'Upload failed.'); + saved(result); + } else saved(await postJson('/von/api/participants/avatar', { concept_id: profile.concept_id, scope: scope.value, ...candidate })); + })); + use.disabled = true; + const remove = button('Remove avatar', () => run('Removing avatar…', async () => { + saved(await postJson('/von/api/participants/avatar', { concept_id: profile.concept_id, scope: scope.value, remove: true })); + candidate = null; + preview.hidden = true; + })); + body.append(input, prompt, generate, preview, use, remove); + dialog.addEventListener('close', () => { if (objectUrl) URL.revokeObjectURL(objectUrl); }); + } catch (error) { status.textContent = error.message || 'Profile unavailable.'; } +} diff --git a/src/frontend/web/von_interface/static/js/domUtils.js b/src/frontend/web/von_interface/static/js/domUtils.js index 3b0f2c5a2..52d27b9df 100644 --- a/src/frontend/web/von_interface/static/js/domUtils.js +++ b/src/frontend/web/von_interface/static/js/domUtils.js @@ -1851,7 +1851,10 @@ export async function setModelInfoFooterText() { span.className = 'footer-segment'; span.textContent = seg; footer.appendChild(span); - } else { footer.appendChild(seg); } + } else { + if (seg.querySelector?.('.footer-label-inline')?.textContent?.trim().startsWith('Model:')) seg.classList.add('conversation-model-controls'); + footer.appendChild(seg); + } if (idx < segments.length - 1) { const sep = document.createElement('span'); sep.className = 'footer-separator'; diff --git a/src/frontend/web/von_interface/static/js/dynamicTabs.js b/src/frontend/web/von_interface/static/js/dynamicTabs.js index 48e627c5e..06dfd6d3d 100644 --- a/src/frontend/web/von_interface/static/js/dynamicTabs.js +++ b/src/frontend/web/von_interface/static/js/dynamicTabs.js @@ -1,3 +1,4 @@ +import { profileButton } from './components/participantProfile.js'; // Dynamic Tab Management for Von Application // Handles creation and management of concept tabs based on vontology selection @@ -2310,6 +2311,11 @@ export async function loadDynamicConceptTabContent(tabId, conceptId) { attachConceptIdCopyChip(headerDiv, conceptId, kind); attachRawDataButton(headerDiv, conceptId, kind, tabInfo.content); attachKeyConceptStarButton(headerDiv, conceptId); + if (!headerDiv.querySelector('.participant-profile-button')) { + const profile = profileButton(conceptId); + profile.className = 'participant-profile-button'; + headerDiv.append(profile); + } if (nodeJson) { attachAnalysisButtons(headerDiv, conceptId, kind, nodeJson); } diff --git a/src/frontend/web/von_interface/static/js/main.js b/src/frontend/web/von_interface/static/js/main.js index 5ceb7abb2..68db51f99 100644 --- a/src/frontend/web/von_interface/static/js/main.js +++ b/src/frontend/web/von_interface/static/js/main.js @@ -386,13 +386,8 @@ async function syncFlaskSessionOrg() { // Personal is an explicit per-tab binding. Materialise it even when the // cookie fallback also happens to be null, so later calls cannot drift to // another tab's organisation through a non-window fallback. - if (!isWindowSessionSource && hasSessionPersonalOrgContext()) { - console.log('[main] Binding explicit Personal context to this window session'); - await postJson('/von/api/session/set_organisation', { - organisation_concept_id: null - }); - } else if (context.organisation_id && !isWindowSessionSource) { - console.log('[main] Clearing stale Flask session org - no org in storage'); + if (!isWindowSessionSource) { + console.log('[main] Binding Personal context to this window session'); await postJson('/von/api/session/set_organisation', { organisation_concept_id: null }); @@ -649,7 +644,7 @@ function setupDynamicLayout() { ); const tabHeight = measuredTabHeight; const contentTop = headerHeight + tabHeight + 8; // 8px margin - const footerSpace = 64; // Fixed footer overlap space + const footerSpace = Math.max(64, Math.ceil(document.querySelector('.footer-container')?.getBoundingClientRect().height || 0)); document.documentElement.style.setProperty('--von-fixed-shell-height', `${contentTop}px`); document.documentElement.style.setProperty('--von-fixed-footer-clearance', `${footerSpace}px`); @@ -713,6 +708,13 @@ function setupDynamicLayout() { } } + const footerEl = document.querySelector('.footer-container'); + if (footerEl && !footerEl._dynamicLayoutObserved && typeof ResizeObserver === 'function') { + const footerObserver = new ResizeObserver(() => requestAnimationFrame(updateLayout)); + footerObserver.observe(footerEl); + footerEl._dynamicLayoutObserved = true; + } + if (!document._dynamicTabStripLayoutListenerBound) { document.addEventListener('von:tab-strip-layout-changed', () => { requestAnimationFrame(updateLayout); diff --git a/src/frontend/web/von_interface/static/js/settingsPage.js b/src/frontend/web/von_interface/static/js/settingsPage.js index 58bbff5ed..9aa366691 100644 --- a/src/frontend/web/von_interface/static/js/settingsPage.js +++ b/src/frontend/web/von_interface/static/js/settingsPage.js @@ -1,3 +1,4 @@ +import { profileButton } from './components/participantProfile.js'; import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_TRAY_HOVER_KEY, loadConversationLayoutPreferences, normaliseConversationLayout, saveConversationLayoutPreference } from './utils/conversationLayoutPreferences.js'; import { supportsAudioRecording } from './dictation.js'; import { ensureUniqueWindowSessionId, getWindowSessionId, postJson, WINDOW_SESSION_HEADER } from './apiService.js'; @@ -8,6 +9,7 @@ import { subscribeBackgroundTaskUpdates } from './backgroundTaskTracker.js'; import { + getSessionContext, normaliseOrganisationDisplayName, renderOrgSelector, setupOrgSwitchListener, @@ -1105,6 +1107,7 @@ function resolveDisplayedProviderModels(settings) { } async function syncInitialScopedSelections({ + readSessionContext = getSessionContext, setUserConcept = async (userConceptId) => postJson('/von/api/session/set_user_concept', { user_concept_id: userConceptId }), switchOrganisationFn = switchOrganisation, @@ -1123,8 +1126,17 @@ async function syncInitialScopedSelections({ throw new Error('No authenticated user is available for scoped model settings.'); } try { - const userResponse = await setUserConcept(userData.concept_id); - const organisationResponse = await switchOrganisationFn( + // Settings loads in a background iframe. Rebinding an already established + // window actor emits an organisation switch and erases an active message + // selection (or an in-flight compose). Read the actual server binding first; + // browser preferences alone are not evidence that the actor is established. + const context = await readSessionContext().catch(() => null); + const alreadyBound = context?.authenticated === true + && context.context_source === 'window_session' + && context.user_id === userData.concept_id + && (context.organisation_id || null) === (orgData?.concept_id || null); + const userResponse = alreadyBound ? context : await setUserConcept(userData.concept_id); + const organisationResponse = alreadyBound ? context : await switchOrganisationFn( orgData?.concept_id || null, orgData?.name || null, ); @@ -6857,3 +6869,10 @@ async function shutdownServer() { if (statusEl) { statusEl.textContent = 'Shutdown failed: ' + e.message; statusEl.className = 'status-message error'; } } } + +function mountParticipantSettings() { + const host = document.getElementById('participantProfileSettings'); + if (host && !host.children.length) host.append(profileButton(null, 'Edit my profile and avatar')); +} +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountParticipantSettings); +else mountParticipantSettings(); diff --git a/src/frontend/web/von_interface/static/js/tabNavigation.js b/src/frontend/web/von_interface/static/js/tabNavigation.js index 3db311aac..5df94d30d 100644 --- a/src/frontend/web/von_interface/static/js/tabNavigation.js +++ b/src/frontend/web/von_interface/static/js/tabNavigation.js @@ -50,6 +50,7 @@ export function setupTabNavigation() { } export function activateTab(tabId) { + if (tabId === 'messagesTab') tabId = 'chatTab'; console.log(`Activating tab: ${tabId}`); if (isTabGuarded(tabId)) { console.warn(`[tabNavigation] Tab ${tabId} is disabled by feature flag; falling back to chat.`); diff --git a/src/frontend/web/von_interface/static/js/test/chatTab.test.js b/src/frontend/web/von_interface/static/js/test/chatTab.test.js index f8eb2c885..7f3a687c4 100644 --- a/src/frontend/web/von_interface/static/js/test/chatTab.test.js +++ b/src/frontend/web/von_interface/static/js/test/chatTab.test.js @@ -382,6 +382,7 @@ jest.mock('../domUtils.js', () => ({ renderSpanSuggestions: jest.fn() })); jest.mock('../utils/sessionScopedStorage.js', () => ({ + getSessionScopedOrgId: jest.fn(() => null), getSessionScopedNamespace: jest.fn(), getSessionScopedOrgContext: jest.fn() })); diff --git a/src/frontend/web/von_interface/static/js/test/conversationHistoryPreferences.test.js b/src/frontend/web/von_interface/static/js/test/conversationHistoryPreferences.test.js index 4c2efd049..0e0d9492a 100644 --- a/src/frontend/web/von_interface/static/js/test/conversationHistoryPreferences.test.js +++ b/src/frontend/web/von_interface/static/js/test/conversationHistoryPreferences.test.js @@ -25,7 +25,7 @@ describe('conversationHistoryPreferences', () => { expect(clampConversationHistoryRecentWindowDays(99999)).toBe(3650); }); - test('selects sessions by recency window and access order with limit', () => { + test('orders by published contributions even when access history disagrees', () => { const nowMs = Date.parse('2026-02-16T00:00:00Z'); const sessions = [ { session_id: 'a', last_message_at: '2026-02-15T10:00:00Z' }, @@ -50,7 +50,7 @@ describe('conversationHistoryPreferences', () => { expect(limited.totalMatchingCount).toBe(3); expect(limited.hiddenByLimitCount).toBe(1); expect(limited.olderThanWindowCount).toBe(1); - expect(limited.sessionsToRender.map((s) => s.session_id)).toEqual(['b', 'a']); + expect(limited.sessionsToRender.map((s) => s.session_id)).toEqual(['a', 'b']); const expanded = selectConversationHistorySessions({ sessions, @@ -60,7 +60,7 @@ describe('conversationHistoryPreferences', () => { showAll: true, nowMs }); - expect(expanded.sessionsToRender.map((s) => s.session_id)).toEqual(['b', 'a', 'c']); + expect(expanded.sessionsToRender.map((s) => s.session_id)).toEqual(['a', 'b', 'c']); }); test('reads persisted settings using configured storage keys', () => { diff --git a/src/frontend/web/von_interface/static/js/utils/conversationHistoryPreferences.js b/src/frontend/web/von_interface/static/js/utils/conversationHistoryPreferences.js index a056027b2..b949c798a 100644 --- a/src/frontend/web/von_interface/static/js/utils/conversationHistoryPreferences.js +++ b/src/frontend/web/von_interface/static/js/utils/conversationHistoryPreferences.js @@ -83,15 +83,6 @@ export function loadConversationHistorySettings(readStorage = null) { }; } -function getAccessTimestampMs(session, accessTimestampBySessionId = {}) { - const sessionId = (typeof session?.session_id === 'string') ? session.session_id.trim() : ''; - const sessionAccessTs = sessionId ? accessTimestampBySessionId[sessionId] : null; - return parseIsoTimestampMs(sessionAccessTs) - ?? parseIsoTimestampMs(session?.last_accessed_at) - ?? getSessionOccurredAtMs(session) - ?? 0; -} - export function selectConversationHistorySessions({ sessions = [], accessTimestampBySessionId = {}, @@ -107,7 +98,7 @@ export function selectConversationHistorySessions({ let olderThanWindowCount = 0; const sessionsInWindow = sessions.filter((session) => { const occurredAtMs = getSessionOccurredAtMs(session); - if (occurredAtMs !== null && occurredAtMs < cutoffMs) { + if (!(session?.shared_unread_count > 0) && occurredAtMs !== null && occurredAtMs < cutoffMs) { olderThanWindowCount += 1; return false; } @@ -115,8 +106,8 @@ export function selectConversationHistorySessions({ }); const sorted = sessionsInWindow.slice().sort((a, b) => ( - getAccessTimestampMs(b, accessTimestampBySessionId) - - getAccessTimestampMs(a, accessTimestampBySessionId) + (getSessionOccurredAtMs(b) || 0) - (getSessionOccurredAtMs(a) || 0) + || String(a?.session_id || '').localeCompare(String(b?.session_id || '')) )); const limited = showAll ? sorted : sorted.slice(0, safeLimit); diff --git a/src/frontend/web/von_interface/static/js/utils/messageStreamReference.js b/src/frontend/web/von_interface/static/js/utils/messageStreamReference.js index c73975e9e..145a5dbd9 100644 --- a/src/frontend/web/von_interface/static/js/utils/messageStreamReference.js +++ b/src/frontend/web/von_interface/static/js/utils/messageStreamReference.js @@ -1,15 +1,18 @@ /** Participant stream identity; this is not a chat-history session reference. */ -export function buildMessageStreamReference({ currentUserId, otherUserId, messages = [], displayName = '' }) { +export function buildMessageStreamReference({ currentUserId, otherUserId, messages = [], displayName = '', participantIds = null, organisationConceptId = undefined }) { if (!currentUserId || !otherUserId) return null; + const participants = [...new Set(participantIds || [currentUserId, otherUserId])].sort(); + const exact = Array.isArray(participantIds); return { kind: 'von_message_stream_ref', - schema_version: 'message_stream_reference.v1', + schema_version: exact ? 'message_stream_reference.v2' : 'message_stream_reference.v1', message_stream_ref: { - participant_concept_ids: [...new Set([currentUserId, otherUserId])].sort(), + participant_concept_ids: participants, + ...(exact ? { organisation_concept_id: organisationConceptId ?? null } : {}), viewer_concept_id: currentUserId, other_user_concept_id: otherUserId, }, - message_lookup: { + message_lookup: exact ? { method: 'POST /api/messages/exchange', participant_ids: participants, organisation_concept_id: organisationConceptId ?? null } : { method: 'message_list_direct', other_user_concept_id: otherUserId, include_sent: true, diff --git a/src/frontend/web/von_interface/static/js/vontology.js b/src/frontend/web/von_interface/static/js/vontology.js index 21cff3bb6..9dfdae0a9 100644 --- a/src/frontend/web/von_interface/static/js/vontology.js +++ b/src/frontend/web/von_interface/static/js/vontology.js @@ -3549,7 +3549,7 @@ async function fetchConceptSearchItems(q, signal) { return sortConceptSearchItems(items); } -async function fetchConversationSearchPage(query, { +async function fetchChatConversationSearchPage(query, { cursor = null, signal = null, trashedOnly = false, @@ -3578,6 +3578,30 @@ async function fetchConversationSearchPage(query, { return data; } +async function fetchConversationSearchPage(query, options = {}) { + if (options.trashedOnly) return fetchChatConversationSearchPage(query, options); + let position = { chat: options.cursor || null, messageSkip: 0, chatDone: false, messageDone: false }; + if (String(options.cursor || '').startsWith('catalogue:')) position = JSON.parse(decodeURIComponent(options.cursor.slice(10))); + const size = options.pageSize || 8; + const [chat, messages] = await Promise.allSettled([ + position.chatDone ? Promise.resolve({ results: [] }) : fetchChatConversationSearchPage(query, { ...options, cursor: position.chat }), + position.messageDone ? Promise.resolve({ conversations: [] }) : vontologyFetch(`/api/messages/catalogue?all_contexts=true&q=${encodeURIComponent(query)}&limit=${size}&skip=${position.messageSkip}`, { signal: options.signal }).then(async response => { + if (!response.ok) throw new Error('Message search unavailable.'); + return response.json(); + }) + ]); + if (chat.status === 'rejected' && messages.status === 'rejected') throw chat.reason; + const data = chat.status === 'fulfilled' ? chat.value : { results: [] }; + const direct = messages.status === 'fulfilled' ? messages.value : { conversations: [] }; + const next = { chat: data.next_cursor || null, chatDone: !data.next_cursor, messageSkip: direct.next_skip || 0, messageDone: !direct.has_more }; + return { + ...data, + results: [...(data.results || []), ...(direct.conversations || []).map(row => ({ ...row, display_name: row.session_name, match: { snippet: row.preview } }))], + next_cursor: next.chatDone && next.messageDone ? null : `catalogue:${encodeURIComponent(JSON.stringify(next))}`, + index_coverage: { ...data.index_coverage, complete_for_accessible_window: chat.status === 'fulfilled' && messages.status === 'fulfilled' && data.index_coverage?.complete_for_accessible_window !== false }, + }; +} + export async function performVontologySearch(q) { const results = elements.vontologySearchResults; const trimmedQuery = String(q || '').trim(); diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html index 071e603a5..c32d4db7e 100644 --- a/src/frontend/web/von_interface/templates/chat_tab.html +++ b/src/frontend/web/von_interface/templates/chat_tab.html @@ -22,6 +22,7 @@ aria-valuemax="400" aria-valuenow="260" aria-controls="chatSessionTabs" title="Drag to resize, or use left and right arrow keys" data-keep-title="true">
+
diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index d6cfa54be..dbf76fe26 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -8,6 +8,7 @@ + @@ -41,6 +42,7 @@

Set up Von for this browser

Signed-in Identity

+
diff --git a/src/frontend/web/von_interface/templates/von_interface.html b/src/frontend/web/von_interface/templates/von_interface.html index dd09ce66c..b03adc851 100644 --- a/src/frontend/web/von_interface/templates/von_interface.html +++ b/src/frontend/web/von_interface/templates/von_interface.html @@ -17,6 +17,7 @@ }; + @@ -102,7 +103,7 @@

-
Conversations
+
Conversations
{% if expert_tabs_enabled %}
Vontology
@@ -120,12 +121,6 @@

All Tasks

-
- - - Messages -
@@ -171,13 +166,6 @@

- -
-
-
Loading messages...
-
-
-
Loading Workflow Studio…
diff --git a/tests/backend/test_chat_history_session_summaries_resilience.py b/tests/backend/test_chat_history_session_summaries_resilience.py index 285d81865..c7f4675c8 100644 --- a/tests/backend/test_chat_history_session_summaries_resilience.py +++ b/tests/backend/test_chat_history_session_summaries_resilience.py @@ -187,7 +187,7 @@ def test_light_session_summaries_sort_and_limit_before_materialising(monkeypatch assert collection.cursor is not None assert collection.cursor.sort_calls == [ - [("updated_at", -1), ("created_at", -1)] + [("last_contribution_at", -1), ("session_id", 1)] ] assert collection.cursor.limit_calls == [3] assert collection.cursor.max_time_ms_calls == [] diff --git a/tests/backend/test_conversation_activity.py b/tests/backend/test_conversation_activity.py new file mode 100644 index 000000000..5bb95ac63 --- /dev/null +++ b/tests/backend/test_conversation_activity.py @@ -0,0 +1,109 @@ +from datetime import UTC, datetime + +import pytest +from flask import Flask + +from src.backend.security import access_control +from src.backend.server.routes import von_routes +from src.backend.services import chat_history_service as chats +from src.backend.services import conversation_read_service as reads + +STAMP = datetime(2026, 9, 11, 19, 1, 15, 659000, tzinfo=UTC) + + +def test_history_keeps_precise_observed_timestamp(): + segments = chats._split_history_into_segments_with_locations( + [ + { + "role": "assistant", + "content": "Answer", + "timestamp": STAMP, + "turn_id": "turn-1", + } + ], + session_id="session-1", + ) + assert segments[0][0]["contribution_timestamp"] == STAMP.isoformat() + assert segments[0][0]["timestamp"] == STAMP + + +@pytest.mark.parametrize( + "message,visible,incoming", + [ + ({"role": "assistant", "content": "Answer"}, True, True), + ( + {"role": "user", "content": "Question", "author_user_id": "#V#alice"}, + True, + False, + ), + ( + { + "role": "user", + "content": "Shared contribution", + "author_user_id": "#V#bob", + }, + True, + True, + ), + ( + {"role": "user", "image_attachments": [{"concept_id": "#V#image"}]}, + True, + False, + ), + ({"role": "tool", "content": "Heartbeat"}, False, False), + ({"role": "assistant", "content": ""}, False, False), + ], +) +def test_only_visible_contributions_change_activity(message, visible, incoming): + fields = chats._conversation_activity_fields(message, "#V#alice", STAMP) + assert ("last_contribution_at" in fields) == visible + assert ("last_incoming_contribution_at" in fields) == incoming + + +@pytest.mark.parametrize( + "observed,turn_id,status", + [ + (STAMP.isoformat(), None, 200), + ("Fri, 11 Sep 2026 19:01:15 GMT", "turn-1", 200), + ("2026-09-11T19:01:15Z", "unseen-turn", 409), + ("2099-01-01T00:00:00Z", None, 409), + ], +) +def test_read_receipt_requires_an_exact_observed_contribution( + monkeypatch, observed, turn_id, status +): + monkeypatch.setattr( + access_control, "get_effective_user_concept_id", lambda: "#V#alice" + ) + monkeypatch.setattr( + von_routes, "get_effective_context", lambda *a, **k: {"namespace": "alice@lab"} + ) + + def summary(actor, sid, **kwargs): + assert actor == "#V#alice" and kwargs["namespace"] == "alice@lab" + return { + "last_incoming_contribution_at": STAMP.isoformat(), + "last_incoming_timestamp": STAMP.isoformat(), + "last_incoming_turn_id": "turn-1", + } + + monkeypatch.setattr(chats, "get_chat_history_session_summary", summary) + acknowledged = [] + monkeypatch.setattr( + reads, + "acknowledge", + lambda *args: acknowledged.append(args) or STAMP.isoformat(), + ) + app = Flask(__name__) + app.secret_key = "activity-test-only" + app.register_blueprint(von_routes.von_bp, url_prefix="/von") + response = app.test_client().post( + "/von/history/read", + json={ + "session_id": "session-1", + "observed_timestamp": observed, + "observed_turn_id": turn_id, + }, + ) + assert response.status_code == status + assert len(acknowledged) == (1 if status == 200 else 0) diff --git a/tests/backend/test_conversation_catalogue.py b/tests/backend/test_conversation_catalogue.py new file mode 100644 index 000000000..3cd24dd88 --- /dev/null +++ b/tests/backend/test_conversation_catalogue.py @@ -0,0 +1,174 @@ +from datetime import UTC, datetime, timedelta + +import mongomock +import pytest +from flask import Flask + +from src.backend.services import conversation_catalogue_service as catalogue +from src.backend.services import conversation_read_service as reads + + +@pytest.fixture +def data(monkeypatch): + db = mongomock.MongoClient().db + monkeypatch.setattr( + catalogue.chats, + "get_chat_history_collection_service", + lambda **kwargs: db.history, + ) + monkeypatch.setattr( + catalogue.chats, "backfill_conversation_activity_metadata", lambda *a, **k: None + ) + monkeypatch.setattr( + catalogue, "list_accepted_invites_for_user", lambda **kwargs: [] + ) + monkeypatch.setattr( + catalogue, + "apply_conversation_preferences", + lambda **kwargs: kwargs["conversations"], + ) + monkeypatch.setattr(catalogue, "project_unread", lambda actor, rows: rows) + monkeypatch.setattr( + reads, "get_application_settings_collection", lambda: db.settings + ) + monkeypatch.setattr( + reads.settings_service, + "get_settings_batch", + lambda keys: { + r["setting_name"]: r["value"] + for r in db.settings.find({"setting_name": {"$in": keys}}) + }, + ) + monkeypatch.setattr( + reads.settings_service, + "get_setting", + lambda key: (db.settings.find_one({"setting_name": key}) or {}).get("value"), + ) + return db + + +def test_unconsumed_candidates_and_equal_times_page_without_omission(data, monkeypatch): + stamp = datetime(2026, 9, 11, tzinfo=UTC) + for i in range(205): + data.history.insert_one( + { + "user_id": "#V#alice", + "namespace": "alice@lab", + "session_id": f"s{i:03}", + "last_contribution_at": stamp - timedelta(seconds=i // 2), + "created_at": stamp, + } + ) + # Two message exchanges competing with a much deeper ordinary history. + messages = [ + { + "session_id": f"messages:{i}", + "catalogue_sort_key": f"message:{i}", + "last_message_at": (stamp - timedelta(seconds=i)).isoformat(), + "source_kind": "message_exchange", + } + for i in (1, 20) + ] + + def message_page(actor, **kwargs): + before = kwargs["before"] + remaining = [ + r + for r in messages + if not before + or r["last_message_at"] < before["timestamp"] + or ( + r["last_message_at"] == before["timestamp"] + and r["catalogue_sort_key"] > before["key"] + ) + ] + return { + "conversations": remaining[: kwargs["limit"]], + "has_more": len(remaining) > kwargs["limit"], + } + + monkeypatch.setattr(catalogue.messages, "list_exchanges", message_page) + app = Flask(__name__) + app.secret_key = "catalogue-test-only" + seen, cursor = [], None + with app.app_context(): + for _ in range(30): + page = catalogue.list_catalogue( + "#V#alice", namespace="alice@lab", limit=10, cursor=cursor + ) + assert page["coverage_complete"] + seen.extend(row["session_id"] for row in page["conversations"]) + cursor = page["next_cursor"] + if not cursor: + break + assert len(seen) == len(set(seen)) == 207 + assert seen[:6] == ["s000", "s001", "s002", "s003", "messages:1", "s004"] + + +def test_owner_and_namespace_constraints_apply_before_paging(data, monkeypatch): + for actor, namespace, sid in [ + ("#V#alice", "alice@lab", "mine"), + ("#V#eve", "alice@lab", "private"), + ("#V#alice", "alice@other", "elsewhere"), + ]: + data.history.insert_one( + { + "user_id": actor, + "namespace": namespace, + "session_id": sid, + "last_contribution_at": datetime.now(UTC), + } + ) + monkeypatch.setattr( + catalogue.messages, + "list_exchanges", + lambda *a, **k: {"conversations": [], "has_more": False}, + ) + page = catalogue.list_catalogue("#V#alice", namespace="alice@lab") + assert [r["session_id"] for r in page["conversations"]] == ["mine"] + + +def test_failed_source_remains_explicit_and_does_not_advance_past_missing_rows( + data, monkeypatch +): + monkeypatch.setattr( + catalogue, + "_chat_page", + lambda *a: (_ for _ in ()).throw(RuntimeError("offline")), + ) + monkeypatch.setattr( + catalogue.messages, + "list_exchanges", + lambda *a, **k: { + "conversations": [{"session_id": "m", "catalogue_sort_key": "message:m"}], + "has_more": True, + }, + ) + page = catalogue.list_catalogue("#V#alice", namespace="alice@lab") + assert not page["coverage_complete"] and page["coverage"]["messages"] + assert page["conversations"][0]["session_id"] == "m" and page["next_cursor"] is None + + +def test_read_baseline_is_quiet_then_incoming_activity_is_per_viewer_and_monotonic( + data, +): + old = datetime.now(UTC) - timedelta(days=1) + rows = [{"session_id": "s", "last_incoming_contribution_at": old}] + assert reads.project_unread("alice", rows)[0]["shared_unread_count"] == 0 + baseline = reads.settings_service.get_setting(reads._key("alice", "__baseline__")) + fresh = datetime.now(UTC) + timedelta(seconds=1) + rows[0]["last_incoming_contribution_at"] = fresh + assert reads.project_unread("alice", rows)[0]["shared_unread_count"] == 1 + reads.acknowledge("alice", "s", fresh) + reads.acknowledge("alice", "s", old) + assert reads.project_unread("alice", rows)[0]["shared_unread_count"] == 0 + assert ( + reads.settings_service.get_setting(reads._key("alice", "__baseline__")) + == baseline + ) + assert reads.settings_service.get_setting(reads._key("bob", "s")) is None + + +def test_shared_receipts_are_not_reset_by_normal_conversation_projection(data): + rows = [{"session_id": "shared", "shared_with_me": True, "shared_unread_count": 3}] + assert reads.project_unread("alice", rows)[0]["shared_unread_count"] == 3 diff --git a/tests/backend/test_message_exchange_catalogue.py b/tests/backend/test_message_exchange_catalogue.py new file mode 100644 index 000000000..fddd52c7c --- /dev/null +++ b/tests/backend/test_message_exchange_catalogue.py @@ -0,0 +1,93 @@ +from datetime import UTC, datetime, timedelta + +import mongomock +import pytest + +from src.backend.services import message_catalogue_service as catalogue + + +@pytest.fixture +def collection(monkeypatch): + monkeypatch.setattr( + catalogue, + "get_user_memberships", + lambda actor: {"memberships": [{"organisation_concept_id": "#V#lab"}]}, + ) + coll = mongomock.MongoClient().db.concepts + monkeypatch.setattr(catalogue, "get_concepts_collection", lambda: coll) + monkeypatch.setattr(catalogue, "apply_concept_query_filter", lambda query: query) + return coll + + +def message(i, recipients=None, org="#V#lab"): + return { + "concept_id": f"#V#message_{i:03}", + "created_at": datetime(2026, 9, 1, tzinfo=UTC) + timedelta(seconds=i), + "relationships": { + "is_an_instance_of": ["#V#direct_message"], + "#V#has_sender": ["#V#bob"], + "#V#has_recipient": recipients or ["#V#alice"], + }, + "concept_data": {"organisation_concept_id": org, "content_fallback": str(i)}, + } + + +def test_latest_page_and_earlier_cursor_are_chronological_without_overlap(collection): + collection.insert_many([message(i) for i in range(65)]) + first = catalogue.get_exchange( + "#V#alice", ["#V#alice", "#V#bob"], organisation="#V#lab" + ) + assert [m["concept_id"] for m in first["messages"]] == [ + f"#V#message_{i:03}" for i in range(15, 65) + ] + earlier = catalogue.get_exchange( + "#V#alice", + ["#V#bob", "#V#alice"], + organisation="#V#lab", + before=first["before"], + ) + assert len(earlier["messages"]) == 15 and not earlier["has_more"] + assert earlier["messages"][-1]["concept_id"] == "#V#message_014" + + +def test_group_exchange_cannot_leak_into_pair_and_org_is_preserved(collection): + collection.insert_many( + [ + message(1), + message(2, ["#V#alice", "#V#carol"]), + message(3, org="#V#elsewhere"), + ] + ) + pair = catalogue.get_exchange( + "#V#alice", ["#V#alice", "#V#bob"], organisation="#V#lab" + ) + assert [m["concept_id"] for m in pair["messages"]] == ["#V#message_001"] + group = catalogue.get_exchange( + "#V#alice", ["#V#alice", "#V#bob", "#V#carol"], organisation="#V#lab" + ) + assert [m["concept_id"] for m in group["messages"]] == ["#V#message_002"] + + +def test_actor_must_be_a_participant(collection): + collection.insert_one(message(1)) + with pytest.raises(PermissionError): + catalogue.get_exchange("#V#eve", ["#V#alice", "#V#bob"]) + + +def test_deleted_messages_and_equal_timestamp_paging(collection): + a, b = message(1), message(2) + b["created_at"] = a["created_at"] + deleted = message(3) + deleted["concept_data"]["deleted"] = True + collection.insert_many([a, b, deleted]) + first = catalogue.get_exchange( + "#V#alice", ["#V#alice", "#V#bob"], organisation="#V#lab", limit=1 + ) + assert first["messages"][0]["concept_id"] == b["concept_id"] + next_page = catalogue.get_exchange( + "#V#alice", + ["#V#alice", "#V#bob"], + organisation="#V#lab", + before=first["before"], + ) + assert [m["concept_id"] for m in next_page["messages"]] == [a["concept_id"]] diff --git a/tests/backend/test_message_routes.py b/tests/backend/test_message_routes.py index 0276be647..e68cd0306 100644 --- a/tests/backend/test_message_routes.py +++ b/tests/backend/test_message_routes.py @@ -511,7 +511,12 @@ def _fake_create_idempotently(**kwargs): ) assert response.status_code == 200 - assert response.get_json() == { + body = response.get_json() + conversation = body.pop("conversation") + assert conversation["participant_ids"] == ["#V#user_alice", "#V#user_bob"] + assert conversation["organisation_concept_id"] == "#V#org_test" + assert conversation["source_kind"] == "message_exchange" + assert body == { "success": True, "effect_status": "succeeded", "changed": False, @@ -616,3 +621,16 @@ def test_send_message_rejects_ambiguous_legacy_idempotency_field( "error": "Use delivery_idempotency_key for direct-message retries", "error_code": "unsupported_idempotency_key_field", } + + +def test_personal_window_offers_shared_organisation_recovery(monkeypatch, app_client): + _, client = app_client + _authorise_test_direct_message(monkeypatch) + monkeypatch.setattr(message_routes, '_get_current_org_concept_id', lambda *a, **k: None) + options = [{"concept_id": "#V#org_test", "name": "Test organisation"}] + monkeypatch.setattr(message_routes, '_build_common_organisation_options', lambda **kwargs: options) + monkeypatch.setattr(message_routes, 'create_message', lambda **kwargs: pytest.fail('No message may be sent before choosing an organisation')) + response = client.post('/api/messages/', json={"recipient_ids": ["#V#user_bob"], "content": "Retain this draft"}) + assert response.status_code == 409 + assert response.get_json()['error_code'] == 'message_organisation_required' + assert response.get_json()['common_organisation_options'] == options diff --git a/tests/backend/test_participant_profiles.py b/tests/backend/test_participant_profiles.py new file mode 100644 index 000000000..2dd29c6a1 --- /dev/null +++ b/tests/backend/test_participant_profiles.py @@ -0,0 +1,168 @@ +import io +from types import SimpleNamespace + +import pytest +from PIL import Image, PngImagePlugin + +from src.backend.services import participant_profile_service as profiles + + +def test_legacy_identity_header_cannot_authorise_profile_effects(monkeypatch): + monkeypatch.setattr(profiles, "get_effective_user_concept_id", lambda: "#V#alice") + monkeypatch.setattr( + profiles, + "get_effective_user_concept_id_with_source", + lambda: ("#V#alice", "legacy_identity_header"), + ) + with pytest.raises(PermissionError, match="authenticated participant"): + profiles.set_avatar(remove=True) + with pytest.raises(PermissionError, match="authenticated participant"): + profiles.generate_avatar(prompt="avatar") + + +@pytest.mark.parametrize( + "source", + [ + "authenticated_session", + "authenticated_session_derived", + "trusted_in_process_actor", + ], +) +def test_trusted_self_profile_effect_is_allowed(monkeypatch, source): + monkeypatch.setattr(profiles, "get_effective_user_concept_id", lambda: "#V#alice") + monkeypatch.setattr( + profiles, + "get_effective_user_concept_id_with_source", + lambda: ("#V#alice", source), + ) + monkeypatch.setattr( + profiles.ConceptsRepository, "find_one", lambda q: {"concept_id": "#V#alice"} + ) + assert profiles._participant(edit=True)[0] == "#V#alice" + + +def test_another_participant_cannot_be_edited(monkeypatch): + monkeypatch.setattr(profiles, "get_effective_user_concept_id", lambda: "#V#alice") + with pytest.raises(PermissionError): + profiles.set_avatar(concept_id="#V#bob", data=b"not an image") + with pytest.raises(PermissionError): + profiles.generate_avatar(concept_id="#V#bob", prompt="avatar") + + +@pytest.mark.parametrize( + "org,expected", [("#V#lab", "combined"), ("#V#elsewhere", "user"), (None, "user")] +) +def test_avatar_scope_precedence_and_context(monkeypatch, org, expected): + monkeypatch.setattr(profiles, "get_effective_user_concept_id", lambda: "#V#alice") + monkeypatch.setattr(profiles, "get_effective_organisation_concept_id", lambda: org) + records = [ + { + "concept_id": name, + "attributes": { + "avatar_subject_id": "#V#alice", + "avatar_scope": scope, + "user_concept_id": "#V#alice", + "organisation_concept_id": "#V#lab", + }, + } + for name, scope in [ + ("global", "global_general"), + ("org", "organisation_general"), + ("user", "user_only_default"), + ("combined", "user_org_default"), + ] + ] + assert profiles._resolve_avatar(records, "#V#alice")["concept_id"] == expected + + +def test_upload_publishes_only_sanitised_derivative_in_requested_scope(monkeypatch): + from src.backend.services import blob_uploads, computer_file_copy_service + + monkeypatch.setattr( + profiles, "_participant", lambda *a, **k: ("#V#alice", "#V#alice", {}) + ) + monkeypatch.setattr( + profiles, "get_effective_organisation_concept_id", lambda: "#V#lab" + ) + monkeypatch.setattr(profiles, "_avatar_records", lambda ids: []) + saved = {} + + def put(**kwargs): + saved.update(kwargs) + return SimpleNamespace( + ref=SimpleNamespace( + key="avatar/key", backend="local", uri="local:avatar/key" + ) + ) + + monkeypatch.setattr(blob_uploads, "put_bytes_durable", put) + + def create(**kwargs): + saved["file"] = kwargs + return SimpleNamespace(concept_id="#V#avatar_file") + + monkeypatch.setattr( + computer_file_copy_service, "create_computer_file_copy_instance", create + ) + monkeypatch.setattr( + profiles.ConceptsRepository, + "find_one", + lambda q: {"attributes": {"sha256": saved["file"]["sha256"]}}, + ) + monkeypatch.setattr(profiles, "get_profile", lambda cid: {"concept_id": cid}) + image = Image.new("RGB", (600, 300), "orange") + metadata = PngImagePlugin.PngInfo() + metadata.add_text("private-note", "private source information") + source = io.BytesIO() + image.save(source, format="PNG", pnginfo=metadata) + result = profiles.set_avatar(data=source.getvalue(), scope="organisation_general") + derivative = Image.open(io.BytesIO(saved["data"])) + assert derivative.size == (256, 256) + assert "private-note" not in derivative.info + assert saved["file"]["visibility_scope_mode"] == "organisation_general" + assert saved["file"]["metadata"]["avatar_subject_id"] == "#V#alice" + assert result["saved_scope"] == "organisation_general" + + +def test_generation_creates_private_preview_without_publishing(monkeypatch): + import base64 + + from src.backend.languagemodels import openai_client + + calls = [] + + class Client: + def with_options(self, **options): + assert options == {"max_retries": 0} + return self + + @property + def images(self): + return self + + def generate(self, **kwargs): + calls.append(kwargs) + return SimpleNamespace( + data=[SimpleNamespace(b64_json=base64.b64encode(b"image").decode())] + ) + + monkeypatch.setattr( + profiles, "_participant", lambda *a, **k: ("#V#alice", "#V#alice", {}) + ) + monkeypatch.setattr( + openai_client, "OpenAIClient", lambda: SimpleNamespace(client=Client()) + ) + monkeypatch.setattr(profiles, "store_image", lambda **kwargs: kwargs) + result = profiles.generate_avatar(prompt="A calm geometric fox") + assert len(calls) == 1 and calls[0]["n"] == 1 + assert result["user_concept_id"] == "#V#alice" + assert result["provenance"]["kind"] == "generated" + + +def test_read_avatar_requires_visible_published_record(monkeypatch): + monkeypatch.setattr( + profiles, "_participant", lambda *a, **k: ("#V#alice", "#V#bob", {}) + ) + monkeypatch.setattr(profiles, "_avatar_records", lambda ids: []) + with pytest.raises(PermissionError): + profiles.load_avatar("#V#bob") diff --git a/tests/frontend/conversationSearch.test.js b/tests/frontend/conversationSearch.test.js index 6af623935..ea9d5d9cd 100644 --- a/tests/frontend/conversationSearch.test.js +++ b/tests/frontend/conversationSearch.test.js @@ -54,6 +54,7 @@ describe('unified global conversation and concept search', () => { test('shows conversations first in the Conversations workspace, with concepts still available', async () => { global.fetch.mockImplementation((url) => { + if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false })); if (String(url).includes('/vontology/api/vontology/search')) { return Promise.resolve(jsonResponse({ results: [{ id: '#V#detector', name: 'Detector', kind: 'type' }] @@ -91,6 +92,7 @@ describe('unified global conversation and concept search', () => { test('one failed provider does not suppress results from the other', async () => { global.fetch.mockImplementation((url) => { + if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false })); if (String(url).includes('/vontology/api/vontology/search')) { return Promise.resolve(jsonResponse({}, { ok: false, status: 503 })); } @@ -110,6 +112,7 @@ describe('unified global conversation and concept search', () => { test('renders the fast provider while the other provider is still pending', async () => { let resolveConversation; global.fetch.mockImplementation((url) => { + if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false })); if (String(url).includes('/vontology/api/vontology/search')) { return Promise.resolve(jsonResponse({ results: [{ id: '#V#fast', name: 'Fast concept', kind: 'type' }] @@ -133,6 +136,7 @@ describe('unified global conversation and concept search', () => { test('passes the opaque cursor and appends more conversations', async () => { let conversationCall = 0; global.fetch.mockImplementation((url) => { + if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false })); const raw = String(url); if (raw.includes('/vontology/api/vontology/search')) { return Promise.resolve(jsonResponse({ results: [] })); @@ -162,6 +166,7 @@ describe('unified global conversation and concept search', () => { test('clearing the native search input immediately invalidates a late response', async () => { let resolveConversation; global.fetch.mockImplementation((url) => { + if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false })); if (String(url).includes('/vontology/api/vontology/search')) { return Promise.resolve(jsonResponse({ results: [] })); } @@ -173,6 +178,7 @@ describe('unified global conversation and concept search', () => { input.value = 'late'; const pending = vontology.performVontologySearch('late'); + await new Promise(resolve => setTimeout(resolve, 0)); input.value = ''; input.dispatchEvent(new Event('search', { bubbles: true })); resolveConversation(jsonResponse(conversationPayload({ @@ -187,6 +193,7 @@ describe('unified global conversation and concept search', () => { test('selecting a conversation dispatches the canonical open event', async () => { global.fetch.mockImplementation((url) => { + if (String(url).includes('/api/messages/catalogue')) return Promise.resolve(jsonResponse({ conversations: [], has_more: false })); if (String(url).includes('/vontology/api/vontology/search')) { return Promise.resolve(jsonResponse({ results: [] })); } diff --git a/tests/frontend/messageExchangeView.test.js b/tests/frontend/messageExchangeView.test.js new file mode 100644 index 000000000..476e885e7 --- /dev/null +++ b/tests/frontend/messageExchangeView.test.js @@ -0,0 +1,77 @@ +jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ getJson: jest.fn(), postJson: jest.fn(), postJsonDetailed: jest.fn() })); +jest.mock('../../src/frontend/web/von_interface/static/js/utils/sessionScopedStorage.js', () => ({ getSessionScopedOrgId: () => '#V#lab' })); +jest.mock('../../src/frontend/web/von_interface/static/js/components/conversationCatalogue.js', () => ({ selectMessageConversation: jest.fn() })); +jest.mock('../../src/frontend/web/von_interface/static/js/utils/selectConceptByIdHandler.js', () => ({ hydrateConceptCartouchesInRoot: jest.fn() })); +jest.mock('../../src/frontend/web/von_interface/static/js/components/participantProfile.js', () => ({ profileButton: () => global.document.createElement('button'), participantAvatar: () => global.document.createElement('span') })); + +const base = '../../src/frontend/web/von_interface/static/js/'; +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); +function row(other) { return { session_id: `messages:${other}`, source_kind: 'message_exchange', viewer_id: '#V#alice', participant_ids: ['#V#alice', other], other_participant_ids: [other], session_name: other, organisation_concept_id: '#V#lab' }; } +function response(id = 'one') { return { current_user_id: '#V#alice', messages: [{ concept_id: id, created_at: '2026-09-11T12:00:00Z', concept_data: { content_fallback: id }, relationships: { '#V#has_sender': ['#V#bob'] } }] }; } + +beforeEach(() => { + jest.resetModules(); + localStorage.clear(); sessionStorage.clear(); + window.scrollTo = jest.fn(); + document.body.innerHTML = '
'; + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); + const api = require(base + 'apiService.js'); + api.postJson.mockResolvedValue(response()); api.getJson.mockResolvedValue({}); +}); + +test('late opening cannot overwrite the newly selected destination', async () => { + const panel = require(base + 'components/messagePanel.js'); + const api = require(base + 'apiService.js'); + let finish; + api.postJson.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const old = panel.openMessageExchange(row('#V#bob')); + await panel.openMessageExchange(row('#V#carol')); + finish(response()); await old; + expect(document.getElementById('conversationTitle').textContent).toBe('#V#carol'); + expect(document.querySelector('.message-reply-destination').textContent).toContain('#V#carol'); +}); + +test('drafts stay with their exact exchange, and group read-only state does not stick to a pair', async () => { + const panel = require(base + 'components/messagePanel.js'); + await panel.openMessageExchange(row('#V#bob')); + document.getElementById('messageInput').value = 'Bob draft'; + const group = row('#V#carol'); group.participant_ids.push('#V#dave'); group.other_participant_ids.push('#V#dave'); + await panel.openMessageExchange(group); + expect(document.getElementById('messageInput').value).toBe(''); + expect(document.getElementById('sendMessageBtn').disabled).toBe(true); + await panel.openMessageExchange(row('#V#bob')); + expect(document.getElementById('messageInput').value).toBe('Bob draft'); + expect(document.getElementById('sendMessageBtn').disabled).toBe(false); +}); + +test('a new-message receipt opens its canonical exchange and preserves the previous draft', async () => { + const panel = require(base + 'components/messagePanel.js'); + await panel.openMessageExchange(row('#V#bob')); + document.getElementById('messageInput').value = 'Keep Bob draft'; + await panel.showMessageComposer(); + document.getElementById('newMessageRecipient').value = '#V#carol'; + document.getElementById('newMessageContent').value = 'Hello Carol'; + require(base + 'apiService.js').postJsonDetailed.mockResolvedValue({ data: { success: true, conversation: row('#V#carol') } }); + document.getElementById('sendNewMessage').click(); await flush(); + expect(require(base + 'components/conversationCatalogue.js').selectMessageConversation).toHaveBeenCalledWith(row('#V#carol')); + expect(document.getElementById('messageInput').value).toBe('Keep Bob draft'); +}); + +test('a refresh reconciles deleted content instead of retaining it forever', async () => { + const panel = require(base + 'components/messagePanel.js'); + await panel.openMessageExchange(row('#V#bob')); + expect(document.querySelector('[data-contribution-id="one"]')).not.toBeNull(); + require(base + 'apiService.js').postJson.mockResolvedValue({ current_user_id: '#V#alice', messages: [] }); + await panel.refreshOpenMessageExchange(); + expect(document.querySelector('[data-contribution-id="one"]')).toBeNull(); +}); + + +test('cancelling a new message restores the ordinary conversation pane', async () => { + document.getElementById('conversationWorkspace').classList.remove('show-message-exchange'); + const panel = require(base + 'components/messagePanel.js'); + await panel.showMessageComposer(); + expect(document.getElementById('conversationWorkspace').classList.contains('show-message-exchange')).toBe(true); + document.getElementById('cancelNewMessage').click(); + expect(document.getElementById('conversationWorkspace').classList.contains('show-message-exchange')).toBe(false); +}); diff --git a/tests/frontend/participantProfile.test.js b/tests/frontend/participantProfile.test.js new file mode 100644 index 000000000..39fe3b55b --- /dev/null +++ b/tests/frontend/participantProfile.test.js @@ -0,0 +1,52 @@ +jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ getJson: jest.fn(), postJson: jest.fn(), ensureUniqueWindowSessionId: jest.fn() })); +const api = require('../../src/frontend/web/von_interface/static/js/apiService.js'); +const { openParticipantProfile } = require('../../src/frontend/web/von_interface/static/js/components/participantProfile.js'); + +beforeEach(() => { + document.body.innerHTML = ''; + HTMLDialogElement.prototype.showModal = function () { this.open = true; }; + HTMLDialogElement.prototype.close = function () { this.open = false; this.dispatchEvent(new Event('close')); }; + api.getJson.mockResolvedValue({ profile: { concept_id: '#V#alice', display_name: 'Alice', can_edit: true, + available_scopes: ['user_org_default', 'user_only_default', 'organisation_general', 'global_general'] } }); + api.postJson.mockReset(); + api.ensureUniqueWindowSessionId.mockResolvedValue("window-1"); +}); + +test('the common editor exposes all supported scopes', async () => { + await openParticipantProfile(); + expect([...document.querySelectorAll('select option')].map(el => el.textContent)).toEqual(['User and organisation', 'User only', 'Organisation', 'Global']); + expect(document.querySelector('input[type=file]').accept).toContain('image/png'); +}); + +test('generated image remains a preview until the user chooses to use it', async () => { + await openParticipantProfile(); + document.querySelector('textarea').value = 'A geometric fox'; + api.postJson.mockResolvedValueOnce({ image: { concept_id: '#V#private_preview', url: '/private/preview' } }); + [...document.querySelectorAll('button')].find(el => el.textContent === 'Generate preview').click(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(api.postJson).toHaveBeenCalledTimes(1); + expect(api.postJson.mock.calls[0][0]).toBe('/von/api/participants/avatar/generate'); + expect(document.querySelector('.participant-avatar-preview').hidden).toBe(false); + document.querySelector('select').value = 'user_only_default'; + api.postJson.mockResolvedValueOnce({ profile: { concept_id: '#V#alice', display_name: 'Alice' } }); + [...document.querySelectorAll('button')].find(el => el.textContent === 'Use avatar').click(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(api.postJson).toHaveBeenLastCalledWith('/von/api/participants/avatar', { concept_id: '#V#alice', scope: 'user_only_default', image_concept_id: '#V#private_preview' }); +}); + +test('viewing another participant offers their concept without edit controls', async () => { + api.getJson.mockResolvedValue({ profile: { concept_id: '#V#bob', display_name: 'Bob', can_edit: false } }); + await openParticipantProfile('#V#bob'); + expect(document.querySelector('input[type=file]')).toBeNull(); + expect(document.body.textContent).toContain('Open concept details'); +}); + + +test('static and scoped avatar URLs both retain a valid query separator', async () => { + const { participantAvatar } = require('../../src/frontend/web/von_interface/static/js/components/participantProfile.js'); + const staticAvatar = participantAvatar({ avatar_url: '/static/VonImageBig.png' }); + const scopedAvatar = participantAvatar({ avatar_url: '/von/api/participants/alice/avatar?v=hash' }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(staticAvatar.querySelector('img').getAttribute('src')).toBe('/static/VonImageBig.png?window_session_id=window-1'); + expect(scopedAvatar.querySelector('img').getAttribute('src')).toBe('/von/api/participants/alice/avatar?v=hash&window_session_id=window-1'); +}); diff --git a/tests/frontend/settingsModelPoolControls.test.js b/tests/frontend/settingsModelPoolControls.test.js index ce6dcfdb2..9e55b70b2 100644 --- a/tests/frontend/settingsModelPoolControls.test.js +++ b/tests/frontend/settingsModelPoolControls.test.js @@ -554,6 +554,36 @@ describe('settings model scope and workflow pool controls', () => { expect(document.getElementById('saveModelPoolButton').disabled).toBe(true); }); + test.each([null, '#V#lab'])('background settings preserves an established window actor in %s', async (organisationId) => { + sessionStorage.setItem('von_current_user', JSON.stringify({ concept_id: '#V#alice' })); + if (organisationId) sessionStorage.setItem('von_current_org', JSON.stringify({ concept_id: organisationId })); + const setUserConcept = jest.fn(); + const switchOrganisationFn = jest.fn(); + const result = await __testOnly_syncInitialScopedSelections({ + readSessionContext: async () => ({ authenticated: true, context_source: 'window_session', user_id: '#V#alice', organisation_id: organisationId, namespace: 'verified-namespace', role: 'member' }), + setUserConcept, switchOrganisationFn, refreshRagStatus: jest.fn(), + }); + expect(result).toMatchObject({ applied: true, namespace: 'verified-namespace', role: 'member' }); + expect(setUserConcept).not.toHaveBeenCalled(); + expect(switchOrganisationFn).not.toHaveBeenCalled(); + }); + + test.each([ + { context_source: 'flask_session', user_id: '#V#alice' }, + { context_source: 'window_session', user_id: '#V#other' }, + { context_source: 'window_session', user_id: '#V#alice', organisation_id: '#V#other-org' }, + ])('initial settings still binds a missing or different window actor: %j', async (context) => { + sessionStorage.setItem('von_current_user', JSON.stringify({ concept_id: '#V#alice' })); + const setUserConcept = jest.fn(async () => ({ namespace: '#V#alice' })); + const switchOrganisationFn = jest.fn(async () => ({ namespace: '#V#alice' })); + await __testOnly_syncInitialScopedSelections({ + readSessionContext: async () => ({ authenticated: true, ...context }), + setUserConcept, switchOrganisationFn, refreshRagStatus: jest.fn(), + }); + expect(setUserConcept).toHaveBeenCalledWith('#V#alice'); + expect(switchOrganisationFn).toHaveBeenCalledWith(null, null); + }); + test('failed initial actor commit restores committed context and leaves Save disabled', async () => { sessionStorage.setItem('von_current_user', JSON.stringify({ concept_id: '#V#restored-user', diff --git a/tests/frontend/unifiedConversationCatalogue.test.js b/tests/frontend/unifiedConversationCatalogue.test.js new file mode 100644 index 000000000..1b0a32783 --- /dev/null +++ b/tests/frontend/unifiedConversationCatalogue.test.js @@ -0,0 +1,68 @@ +jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ getJson: jest.fn(), postJson: jest.fn() })); +jest.mock('../../src/frontend/web/von_interface/static/js/utils/sessionScopedStorage.js', () => ({ getSessionScopedOrgId: jest.fn(() => '#V#lab') })); +jest.mock('../../src/frontend/web/von_interface/static/js/components/messagePanel.js', () => ({ + openMessageExchange: jest.fn(), refreshOpenMessageExchange: jest.fn(), showMessageComposer: jest.fn(), resetMessagePanelContext: jest.fn() +})); + +const api = require('../../src/frontend/web/von_interface/static/js/apiService.js'); +const scope = require('../../src/frontend/web/von_interface/static/js/utils/sessionScopedStorage.js'); +const catalogue = require('../../src/frontend/web/von_interface/static/js/components/conversationCatalogue.js'); +const { selectConversationHistorySessions } = require('../../src/frontend/web/von_interface/static/js/utils/conversationHistoryPreferences.js'); + +function exchange(id, date, unread = 0) { + return { session_id: id, source_kind: 'message_exchange', viewer_id: '#V#alice', participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'], session_name: 'Bob', last_message_at: date, shared_unread_count: unread }; +} + +beforeEach(() => { + catalogue.resetConversationCatalogue(); + jest.clearAllMocks(); + scope.getSessionScopedOrgId.mockReturnValue('#V#lab'); + document.body.innerHTML = '
'; + api.postJson.mockResolvedValue({ profiles: [{ concept_id: '#V#bob', display_name: 'Bob' }] }); +}); + +test('an incoming message promotes its existing row without changing selection or a chat draft', async () => { + api.getJson.mockResolvedValue({ conversations: [exchange('messages:bob', '2026-09-10T12:00:00Z')] }); + await catalogue.refreshMessageCatalogue(); + api.getJson.mockResolvedValue({ conversations: [exchange('messages:bob', '2026-09-11T12:00:00Z', 1)] }); + await catalogue.refreshMessageCatalogue(); + const rows = selectConversationHistorySessions({ sessions: [ + { session_id: 'chat', last_message_at: '2026-09-11T11:00:00Z' }, ...catalogue.directConversationRows() + ], nowMs: Date.parse('2026-09-11T13:00:00Z') }).sessionsToRender; + expect(rows.map(row => row.session_id)).toEqual(['messages:bob', 'chat']); + expect(catalogue.activeMessageConversationId()).toBeNull(); + expect(document.getElementById('draft').value).toBe('Unsaved research question'); +}); + +test('a background normal answer promotes the same conversation ahead of a message exchange', () => { + const normal = { session_id: 'research', last_message_at: '2026-09-11T12:01:00Z' }; + const rows = selectConversationHistorySessions({ sessions: [exchange('messages:bob', '2026-09-11T12:00:00Z'), normal], accessTimestampBySessionId: { 'messages:bob': '2026-09-11T13:00:00Z' }, nowMs: Date.parse('2026-09-11T13:00:00Z') }).sessionsToRender; + expect(rows[0]).toBe(normal); +}); + +test('late organisation responses cannot repopulate the catalogue', async () => { + let finish; + api.getJson.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + const pending = catalogue.refreshMessageCatalogue(); + scope.getSessionScopedOrgId.mockReturnValue('#V#other'); + catalogue.resetConversationCatalogue(); + finish({ conversations: [exchange('messages:private', '2026-09-11T12:00:00Z')] }); + await pending; + expect(catalogue.directConversationRows()).toEqual([]); +}); + +test('profile lookup failure leaves the conversation available', async () => { + api.getJson.mockResolvedValue({ conversations: [exchange('messages:bob', '2026-09-11T12:00:00Z')] }); + api.postJson.mockRejectedValue(new Error('Profile service offline')); + await catalogue.refreshMessageCatalogue(); + expect(catalogue.directConversationRows()).toHaveLength(1); +}); + +test('unread exchanges survive the recency cutoff and equal timestamps have stable order', () => { + const old = exchange('messages:old', '2020-01-01T00:00:00Z', 1); + const rows = selectConversationHistorySessions({ sessions: [ + { session_id: 'b', last_message_at: '2026-09-11T12:00:00Z' }, old, + { session_id: 'a', last_message_at: '2026-09-11T12:00:00Z' } + ], nowMs: Date.parse('2026-09-11T13:00:00Z') }).sessionsToRender; + expect(rows.map(row => row.session_id)).toEqual(['a', 'b', 'messages:old']); +});