diff --git a/.gitignore b/.gitignore index 6fad4cde..05e2a5ac 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ nul /artifacts/tmp scripts/agent.json scripts/me.json +.github/instructions/python-venv-path.instructions.md \ No newline at end of file diff --git a/application/single_app/app.py b/application/single_app/app.py index c1afa7c6..42213365 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -952,8 +952,9 @@ def list_semantic_kernel_plugins(): # ------------------- API Thoughts Routes ---------------- register_route_backend_thoughts(app) -# ------------------- Extenral Health Routes ---------- +# ------------------- External Health Routes ---------- register_route_external_health(app) +register_no_auth_health(app) if __name__ == '__main__': debug_mode = os.environ.get("FLASK_DEBUG", "0") == "1" diff --git a/application/single_app/config.py b/application/single_app/config.py index 7958b14a..f1672023 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -115,6 +115,7 @@ #"font-src 'self' https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; " "connect-src 'self' https: wss: ws:; " "media-src 'self' blob:; " + "frame-src 'self' blob:; " "object-src 'none'; " "frame-ancestors 'self'; " "base-uri 'self';" diff --git a/application/single_app/functions_activity_logging.py b/application/single_app/functions_activity_logging.py index f5337562..92c78dfb 100644 --- a/application/single_app/functions_activity_logging.py +++ b/application/single_app/functions_activity_logging.py @@ -14,6 +14,24 @@ from config import cosmos_activity_logs_container +def coerce_activity_log_user_id(user_id: Any) -> str: + """Extract a stable string user id from a scalar or session-style identity payload.""" + if user_id is None: + return '' + + if isinstance(user_id, str): + return user_id.strip() + + if isinstance(user_id, dict): + for key in ('oid', 'sub', 'id', 'user_id'): + candidate = user_id.get(key) + if isinstance(candidate, str) and candidate.strip(): + return candidate.strip() + return '' + + return str(user_id).strip() + + USER_LOGIN_ACTIVITY_SESSION_KEY = 'last_user_login_activity_epoch' USER_LOGIN_ACTIVITY_MIN_INTERVAL_SECONDS = 15 * 60 @@ -1725,14 +1743,15 @@ def log_general_admin_action( """ try: + normalized_admin_user_id = coerce_activity_log_user_id(admin_user_id) activity_record = { 'id': str(uuid.uuid4()), - 'user_id': admin_user_id, + 'user_id': normalized_admin_user_id, 'activity_type': 'admin_action', 'timestamp': datetime.utcnow().isoformat(), 'created_at': datetime.utcnow().isoformat(), 'admin': { - 'user_id': admin_user_id, + 'user_id': normalized_admin_user_id, 'email': admin_email }, 'action': action, @@ -1759,7 +1778,7 @@ def log_general_admin_action( log_event( message=f"Error logging admin action: {str(e)}", extra={ - 'admin_user_id': admin_user_id, + 'admin_user_id': normalized_admin_user_id, 'admin_email': admin_email, 'action': action, 'error': str(e) diff --git a/application/single_app/functions_appinsights.py b/application/single_app/functions_appinsights.py index c81d17f9..14c013ce 100644 --- a/application/single_app/functions_appinsights.py +++ b/application/single_app/functions_appinsights.py @@ -75,18 +75,60 @@ def is_debug_enabled() -> bool: return bool(settings.get('enable_debug_logging', False)) +def _get_appinsights_debug_logger() -> Optional[logging.Logger]: + """Return a logger that can emit DEBUG traces without widening parent logger levels.""" + base_logger = get_appinsights_logger() + if not base_logger: + return None + + base_name = base_logger.name or 'root' + debug_logger_name = 'appinsights.debug' if base_name == 'root' else f"{base_name}.debug" + debug_logger = logging.getLogger(debug_logger_name) + debug_logger.setLevel(logging.DEBUG) + return debug_logger + + +def _emit_appinsights_debug_trace( + message: str, + category: str, + details: Optional[Dict[str, Any]] = None, +) -> None: + """Send a tagged debug trace to App Insights when Azure Monitor logging is configured.""" + if not _azure_monitor_configured: + return + + debug_logger = _get_appinsights_debug_logger() + if not debug_logger: + return + + trace_properties = dict(details or {}) + trace_properties.setdefault('debug_tag', '[debug]') + trace_properties.setdefault('debug_category', category) + trace_message = f"[debug] [{category}] {message}" + + try: + # Use a child logger so DEBUG traces can flow to App Insights even when the + # parent logger stays at INFO to avoid broad third-party debug noise. + if trace_properties: + debug_logger.debug(trace_message, extra=trace_properties, stacklevel=3) + else: + debug_logger.debug(trace_message, stacklevel=3) + except Exception: + pass + + def debug_print(message: Any, *args: Any, category: str = "INFO", **kwargs: Any) -> None: - """Emit a debug-only console message using the unified logging implementation.""" + """Emit debug-only console output and forward a tagged App Insights trace when available.""" flush = kwargs.pop('flush', False) details = kwargs or None - log_event( - message, - extra=details, - debug_only=True, - category=category, - flush=flush, - message_args=args, - ) + formatted_message = _format_message(message, args) + settings = _load_logging_settings() + + _emit_debug_message(settings, formatted_message, category, flush, details) + if not settings.get('enable_debug_logging', False): + return + + _emit_appinsights_debug_trace(formatted_message, category, details) def get_appinsights_logger(): diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 304660f4..0091d0a3 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -70,7 +70,8 @@ def get_settings(use_cosmos=False, include_source=False): import secrets default_settings = { # External health check - 'enable_external_healthcheck': True, + 'enable_external_healthcheck': False, + 'enable_no_auth_external_healthcheck': False, # Security settings 'enable_appinsights_global_logging': False, 'enable_debug_logging': False, @@ -390,8 +391,6 @@ def get_settings(use_cosmos=False, include_source=False): 'file_timer_value': 1, 'file_timer_unit': 'hours', 'file_processing_logs_turnoff_time': None, - 'enable_external_healthcheck': False, - # Streaming settings 'streamingEnabled': True, diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index 48aa0877..ec681756 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -56,4 +56,4 @@ pyyaml==6.0.2 aiohttp==3.13.4 html2text==2025.4.15 matplotlib==3.10.7 -azure-cognitiveservices-speech==1.47.0 \ No newline at end of file +azure-cognitiveservices-speech==1.48.2 \ No newline at end of file diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index b74508ac..a6df9289 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -1,5 +1,13 @@ # route_backend_control_center.py +import csv +import logging +import math +import time +from io import StringIO + +from flask import make_response + from config import * from functions_authentication import * from functions_settings import * @@ -15,6 +23,10 @@ from functions_debug import debug_print +ACTIVITY_LOGS_DEFAULT_PER_PAGE = 50 +ACTIVITY_LOGS_MAX_PER_PAGE = 200 + + def normalize_token_filter_value(value): """Normalize optional token filter values from query params or request JSON.""" if value is None: @@ -125,6 +137,284 @@ def get_distinct_token_filter_values(query): debug_print(f"[Token Filters] Error loading distinct values: {ex}") return [] + +def validate_activity_logs_pagination(request_args): + """Validate pagination parameters for the interactive activity logs API.""" + page_raw = request_args.get('page', 1) + per_page_raw = request_args.get('per_page', ACTIVITY_LOGS_DEFAULT_PER_PAGE) + + try: + page = int(page_raw) + per_page = int(per_page_raw) + except (TypeError, ValueError) as ex: + raise ValueError('page and per_page must be integers.') from ex + + if page < 1: + raise ValueError('page must be greater than or equal to 1.') + + if per_page < 1: + raise ValueError('per_page must be greater than or equal to 1.') + + if per_page > ACTIVITY_LOGS_MAX_PER_PAGE: + raise ValueError( + f'per_page must be less than or equal to {ACTIVITY_LOGS_MAX_PER_PAGE} for activity log browsing.' + ) + + return page, per_page + + +def build_activity_logs_query_context(activity_type_filter='all', search_term=''): + """Build the shared Cosmos WHERE clause and parameters for activity log queries.""" + query_conditions = [] + parameters = [] + + if activity_type_filter and activity_type_filter != 'all': + query_conditions.append("c.activity_type = @activity_type") + parameters.append({"name": "@activity_type", "value": activity_type_filter}) + + normalized_search_term = (search_term or '').strip().lower() + if normalized_search_term: + query_conditions.append( + "(" + " OR ".join([ + "(IS_DEFINED(c.activity_type) AND CONTAINS(LOWER(c.activity_type), @activity_search_term))", + "(IS_DEFINED(c.user_id) AND CONTAINS(LOWER(c.user_id), @activity_search_term))", + "(IS_DEFINED(c.admin_email) AND CONTAINS(LOWER(c.admin_email), @activity_search_term))", + "(IS_DEFINED(c.requester_email) AND CONTAINS(LOWER(c.requester_email), @activity_search_term))", + "(IS_DEFINED(c.added_by_email) AND CONTAINS(LOWER(c.added_by_email), @activity_search_term))", + "(IS_DEFINED(c.approver_email) AND CONTAINS(LOWER(c.approver_email), @activity_search_term))", + "(IS_DEFINED(c.member_email) AND CONTAINS(LOWER(c.member_email), @activity_search_term))", + "(IS_DEFINED(c.member_name) AND CONTAINS(LOWER(c.member_name), @activity_search_term))", + "(IS_DEFINED(c.group_name) AND CONTAINS(LOWER(c.group_name), @activity_search_term))", + "(IS_DEFINED(c.workspace_name) AND CONTAINS(LOWER(c.workspace_name), @activity_search_term))", + "(IS_DEFINED(c.public_workspace_name) AND CONTAINS(LOWER(c.public_workspace_name), @activity_search_term))", + "(IS_DEFINED(c.login_method) AND CONTAINS(LOWER(c.login_method), @activity_search_term))", + "(IS_DEFINED(c.token_type) AND CONTAINS(LOWER(c.token_type), @activity_search_term))", + "(IS_DEFINED(c.workspace_type) AND CONTAINS(LOWER(c.workspace_type), @activity_search_term))", + "(IS_DEFINED(c.description) AND CONTAINS(LOWER(c.description), @activity_search_term))", + "(IS_DEFINED(c.conversation.title) AND CONTAINS(LOWER(c.conversation.title), @activity_search_term))", + "(IS_DEFINED(c.document.file_name) AND CONTAINS(LOWER(c.document.file_name), @activity_search_term))", + "(IS_DEFINED(c.usage.model) AND CONTAINS(LOWER(c.usage.model), @activity_search_term))", + "(IS_DEFINED(c.workspace_context.group_id) AND CONTAINS(LOWER(c.workspace_context.group_id), @activity_search_term))", + "(IS_DEFINED(c.workspace_context.public_workspace_id) AND CONTAINS(LOWER(c.workspace_context.public_workspace_id), @activity_search_term))" + ]) + ")" + ) + parameters.append({"name": "@activity_search_term", "value": normalized_search_term}) + + where_clause = " WHERE " + " AND ".join(query_conditions) if query_conditions else "" + return where_clause, parameters + + +def normalize_activity_log_value(value): + """Recursively coerce Cosmos activity log values into JSON-safe data.""" + if value is None or isinstance(value, (str, bool, int)): + return value + + if isinstance(value, float): + if math.isnan(value) or math.isinf(value): + return None + return value + + if isinstance(value, datetime): + return value.isoformat() + + if isinstance(value, bytes): + return value.decode('utf-8', errors='replace') + + if isinstance(value, dict): + return { + str(key): normalize_activity_log_value(nested_value) + for key, nested_value in value.items() + } + + if isinstance(value, (list, tuple, set)): + return [normalize_activity_log_value(item) for item in value] + + return str(value) + + +def normalize_activity_log_record(log_record): + """Return a browser-safe activity log document with stable core fields.""" + normalized_record = normalize_activity_log_value(log_record) + if not isinstance(normalized_record, dict): + normalized_record = {'raw_value': normalized_record} + + for field_name in ('user_id', 'admin_user_id', 'added_by_user_id'): + if field_name in normalized_record: + normalized_record[field_name] = coerce_activity_log_user_id(normalized_record.get(field_name)) + + admin_payload = normalized_record.get('admin') + if isinstance(admin_payload, dict): + admin_payload['user_id'] = coerce_activity_log_user_id(admin_payload.get('user_id')) + if not normalized_record.get('user_id') and admin_payload.get('user_id'): + normalized_record['user_id'] = admin_payload.get('user_id') + + normalized_record.setdefault('id', '') + normalized_record.setdefault('activity_type', 'unknown') + normalized_record.setdefault('timestamp', '') + normalized_record.setdefault('workspace_type', '') + return normalized_record + + +def get_activity_log_user_details(user_id, user_cache): + """Resolve a user display payload once and reuse it across pagination or export.""" + user_id = coerce_activity_log_user_id(user_id) + if not user_id: + return {'email': '', 'display_name': ''} + + if user_id in user_cache: + return user_cache[user_id] + + try: + user_doc = cosmos_user_settings_container.read_item(item=user_id, partition_key=user_id) + user_cache[user_id] = { + 'email': user_doc.get('email', ''), + 'display_name': user_doc.get('display_name', '') + } + except Exception as ex: + user_cache[user_id] = {'email': '', 'display_name': ''} + log_event( + '[ControlCenter][ActivityLogs] Failed to resolve activity log user details.', + extra={ + 'activity_log_user_id': user_id, + 'error_type': type(ex).__name__ + }, + debug_only=True, + category='CONTROL_CENTER' + ) + + return user_cache[user_id] + + +def build_activity_log_user_map(logs): + """Build a user map keyed by user_id for the current activity log payload.""" + user_cache = {} + for log_record in logs: + user_id = ( + log_record.get('user_id') + or (log_record.get('admin') or {}).get('user_id') + or log_record.get('admin_user_id') + or log_record.get('added_by_user_id') + ) + user_id = coerce_activity_log_user_id(user_id) + if user_id: + get_activity_log_user_details(user_id, user_cache) + return user_cache + + +def format_activity_log_details_for_csv(log_record): + """Format activity log details as a plain string suitable for CSV export.""" + activity_type = log_record.get('activity_type', '') + + if activity_type == 'user_login': + return f"Login method: {log_record.get('login_method') or log_record.get('details', {}).get('login_method', 'N/A')}" + + if activity_type == 'conversation_creation': + conversation = log_record.get('conversation', {}) + return f"Title: {conversation.get('title', 'Untitled')}, ID: {conversation.get('conversation_id', 'N/A')}" + + if activity_type == 'conversation_deletion': + conversation = log_record.get('conversation', {}) + return f"Deleted: {conversation.get('title', 'Untitled')}, ID: {conversation.get('conversation_id', 'N/A')}" + + if activity_type == 'conversation_archival': + conversation = log_record.get('conversation', {}) + return f"Archived: {conversation.get('title', 'Untitled')}, ID: {conversation.get('conversation_id', 'N/A')}" + + if activity_type == 'document_creation': + document = log_record.get('document', {}) + return f"File: {document.get('file_name', 'Unknown')}, Type: {document.get('file_type', '')}" + + if activity_type == 'document_deletion': + document = log_record.get('document', {}) + return f"Deleted: {document.get('file_name', 'Unknown')}, Type: {document.get('file_type', '')}" + + if activity_type == 'document_metadata_update': + updated_fields = ', '.join((log_record.get('updated_fields') or {}).keys()) or 'N/A' + document = log_record.get('document', {}) + return f"File: {document.get('file_name', 'Unknown')}, Updated: {updated_fields}" + + if activity_type == 'token_usage': + usage = log_record.get('usage', {}) + scope_details = [] + workspace_type = log_record.get('workspace_type') + if workspace_type: + scope_details.append(f"Workspace: {workspace_type}") + workspace_context = log_record.get('workspace_context', {}) + if workspace_context.get('group_id'): + scope_details.append(f"Group: {workspace_context.get('group_id')}") + if workspace_context.get('public_workspace_id'): + scope_details.append(f"Public Workspace: {workspace_context.get('public_workspace_id')}") + scope_suffix = f"; {' | '.join(scope_details)}" if scope_details else '' + return ( + f"Type: {log_record.get('token_type', 'unknown')}, " + f"Tokens: {usage.get('total_tokens', 0)}, " + f"Model: {usage.get('model', 'N/A')}{scope_suffix}" + ) + + if activity_type in {'group_status_change', 'public_workspace_status_change'}: + status_change = log_record.get('status_change', {}) + entity_name = ( + log_record.get('group', {}).get('group_name') + or log_record.get('public_workspace', {}).get('workspace_name') + or log_record.get('workspace_context', {}).get('public_workspace_name') + or log_record.get('group_name') + or log_record.get('workspace_name') + or log_record.get('public_workspace_name') + or 'Unknown' + ) + return ( + f"Name: {entity_name}, " + f"Status: {status_change.get('old_status', 'N/A')} -> {status_change.get('new_status', 'N/A')}" + ) + + if activity_type in { + 'group_member_deleted', + 'add_member_directly', + 'admin_add_member_csv', + 'add_workspace_member_directly', + 'admin_add_workspace_member_csv' + }: + member_name = ( + log_record.get('member_name') + or log_record.get('removed_member', {}).get('name') + or log_record.get('removed_member', {}).get('email') + or log_record.get('member_email') + or 'Unknown' + ) + target_name = ( + log_record.get('group_name') + or log_record.get('group', {}).get('group_name') + or log_record.get('workspace_name') + or log_record.get('public_workspace_name') + or 'Unknown' + ) + role = log_record.get('member_role', '') + role_suffix = f" ({role})" if role else '' + return f"Member: {member_name}, Target: {target_name}{role_suffix}" + + if activity_type in { + 'admin_take_ownership_approved', + 'transfer_ownership_approved', + 'delete_group_approved', + 'delete_all_documents_approved', + 'admin_take_workspace_ownership_approved', + 'transfer_workspace_ownership_approved', + 'delete_workspace_documents_approved', + 'delete_workspace_approved' + }: + return log_record.get('description') or 'Administrative approval activity' + + return log_record.get('description') or 'N/A' + + +def create_activity_log_csv_response(csv_content): + """Create a CSV download response for activity log exports.""" + timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S') + response = make_response(csv_content) + response.headers['Content-Type'] = 'text/csv; charset=utf-8' + response.headers['Content-Disposition'] = f'attachment; filename="activity_logs_{timestamp}.csv"' + return response + def enhance_user_with_activity(user, force_refresh=False): """ Enhance user data with activity information and computed fields. @@ -5751,103 +6041,80 @@ def api_get_activity_logs(): Supports search and filtering by activity type. """ try: - # Get query parameters - page = int(request.args.get('page', 1)) - per_page = int(request.args.get('per_page', 50)) + page, per_page = validate_activity_logs_pagination(request.args) search_term = request.args.get('search', '').strip().lower() activity_type_filter = request.args.get('activity_type_filter', 'all').strip() - - # Build query conditions - query_conditions = [] - parameters = [] - - # Filter by activity type if not 'all' - if activity_type_filter and activity_type_filter != 'all': - query_conditions.append("c.activity_type = @activity_type") - parameters.append({"name": "@activity_type", "value": activity_type_filter}) - - # Build WHERE clause (empty if no conditions) - where_clause = " WHERE " + " AND ".join(query_conditions) if query_conditions else "" - - # Get total count for pagination + + request_started = time.perf_counter() + where_clause, parameters = build_activity_logs_query_context(activity_type_filter, search_term) + + log_event( + '[ControlCenter][ActivityLogs] Loading activity logs page.', + extra={ + 'page': page, + 'per_page': per_page, + 'has_search': bool(search_term), + 'search_length': len(search_term), + 'activity_type_filter': activity_type_filter or 'all' + }, + debug_only=True, + category='CONTROL_CENTER' + ) + + count_started = time.perf_counter() count_query = f"SELECT VALUE COUNT(1) FROM c{where_clause}" total_items_result = list(cosmos_activity_logs_container.query_items( query=count_query, parameters=parameters, enable_cross_partition_query=True )) + count_duration_ms = int((time.perf_counter() - count_started) * 1000) total_items = total_items_result[0] if total_items_result and isinstance(total_items_result[0], int) else 0 - - # Calculate pagination - offset = (page - 1) * per_page + total_pages = (total_items + per_page - 1) // per_page if total_items > 0 else 1 - - # Get paginated results + offset = (page - 1) * per_page logs_query = f""" SELECT * FROM c{where_clause} ORDER BY c.timestamp DESC OFFSET {offset} LIMIT {per_page} """ - - debug_print(f"Activity logs query: {logs_query}") - debug_print(f"Query parameters: {parameters}") - - logs = list(cosmos_activity_logs_container.query_items( - query=logs_query, - parameters=parameters, - enable_cross_partition_query=True - )) - - # Apply search filter in Python (after fetching from Cosmos) - if search_term: - filtered_logs = [] - for log in logs: - # Search in various fields - usage = log.get('usage', {}) - workspace_context = log.get('workspace_context', {}) - searchable_text = ' '.join([ - str(log.get('activity_type', '')), - str(log.get('user_id', '')), - str(log.get('login_method', '')), - str(log.get('conversation', {}).get('title', '')), - str(log.get('document', {}).get('file_name', '')), - str(log.get('token_type', '')), - str(log.get('workspace_type', '')), - str(usage.get('model', '')), - str(workspace_context.get('group_id', '')), - str(workspace_context.get('public_workspace_id', '')) - ]).lower() - - if search_term in searchable_text: - filtered_logs.append(log) - - logs = filtered_logs - # Recalculate total_items for filtered results - total_items = len(logs) - total_pages = (total_items + per_page - 1) // per_page if total_items > 0 else 1 - - # Get unique user IDs from logs - user_ids = set(log.get('user_id') for log in logs if log.get('user_id')) - - # Fetch user information for display names/emails - user_map = {} - if user_ids: - for user_id in user_ids: - try: - user_doc = cosmos_user_settings_container.read_item( - item=user_id, - partition_key=user_id - ) - user_map[user_id] = { - 'email': user_doc.get('email', ''), - 'display_name': user_doc.get('display_name', '') - } - except Exception as ex: - user_map[user_id] = { - 'email': '', - 'display_name': '' - } - + + query_started = time.perf_counter() + logs = [ + normalize_activity_log_record(log_record) + for log_record in cosmos_activity_logs_container.query_items( + query=logs_query, + parameters=parameters, + enable_cross_partition_query=True + ) + ] + query_duration_ms = int((time.perf_counter() - query_started) * 1000) + + user_lookup_started = time.perf_counter() + user_map = build_activity_log_user_map(logs) + user_lookup_duration_ms = int((time.perf_counter() - user_lookup_started) * 1000) + total_duration_ms = int((time.perf_counter() - request_started) * 1000) + + log_event( + '[ControlCenter][ActivityLogs] Activity logs page loaded.', + extra={ + 'page': page, + 'per_page': per_page, + 'returned_items': len(logs), + 'total_items': total_items, + 'total_pages': total_pages, + 'unique_user_count': len(user_map), + 'count_duration_ms': count_duration_ms, + 'query_duration_ms': query_duration_ms, + 'user_lookup_duration_ms': user_lookup_duration_ms, + 'total_duration_ms': total_duration_ms, + 'has_search': bool(search_term), + 'activity_type_filter': activity_type_filter or 'all' + }, + debug_only=True, + category='CONTROL_CENTER' + ) + return jsonify({ 'logs': logs, 'user_map': user_map, @@ -5860,13 +6127,127 @@ def api_get_activity_logs(): 'has_next': page < total_pages } }), 200 - - except Exception as e: - debug_print(f"Error getting activity logs: {e}") - import traceback - traceback.print_exc() + + except ValueError as ex: + log_event( + '[ControlCenter][ActivityLogs] Invalid activity logs request.', + extra={ + 'page': request.args.get('page'), + 'per_page': request.args.get('per_page'), + 'error_message': str(ex) + }, + level=logging.WARNING + ) + return jsonify({'error': str(ex)}), 400 + + except Exception as ex: + log_event( + '[ControlCenter][ActivityLogs] Failed to fetch activity logs.', + extra={ + 'page': request.args.get('page'), + 'per_page': request.args.get('per_page'), + 'search': request.args.get('search', ''), + 'activity_type_filter': request.args.get('activity_type_filter', 'all'), + 'error_type': type(ex).__name__, + 'error_message': str(ex) + }, + level=logging.ERROR, + exceptionTraceback=True + ) return jsonify({'error': 'Failed to fetch activity logs'}), 500 + @app.route('/api/admin/control-center/activity-logs/export', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_export_activity_logs(): + """Export all matching activity logs as CSV without relying on the paged JSON endpoint.""" + try: + search_term = request.args.get('search', '').strip().lower() + activity_type_filter = request.args.get('activity_type_filter', 'all').strip() + export_started = time.perf_counter() + where_clause, parameters = build_activity_logs_query_context(activity_type_filter, search_term) + + log_event( + '[ControlCenter][ActivityLogs] Starting activity log export.', + extra={ + 'has_search': bool(search_term), + 'search_length': len(search_term), + 'activity_type_filter': activity_type_filter or 'all' + }, + debug_only=True, + category='CONTROL_CENTER' + ) + + logs_query = f""" + SELECT * FROM c{where_clause} + ORDER BY c.timestamp DESC + """ + + output = StringIO() + writer = csv.writer(output) + writer.writerow(['Timestamp', 'Activity Type', 'User ID', 'User Email', 'User Name', 'Details', 'Workspace Type']) + + exported_count = 0 + user_cache = {} + for log_record in cosmos_activity_logs_container.query_items( + query=logs_query, + parameters=parameters, + enable_cross_partition_query=True + ): + normalized_log = normalize_activity_log_record(log_record) + resolved_user_id = ( + normalized_log.get('user_id') + or normalized_log.get('admin_user_id') + or normalized_log.get('added_by_user_id') + or '' + ) + user_details = get_activity_log_user_details(resolved_user_id, user_cache) + writer.writerow([ + normalized_log.get('timestamp', ''), + normalized_log.get('activity_type', ''), + resolved_user_id, + user_details.get('email') + or normalized_log.get('admin_email') + or normalized_log.get('requester_email') + or normalized_log.get('added_by_email') + or normalized_log.get('member_email', ''), + user_details.get('display_name') or normalized_log.get('member_name', ''), + format_activity_log_details_for_csv(normalized_log), + normalized_log.get('workspace_type', '') + ]) + exported_count += 1 + + export_duration_ms = int((time.perf_counter() - export_started) * 1000) + log_event( + '[ControlCenter][ActivityLogs] Activity log export completed.', + extra={ + 'exported_count': exported_count, + 'unique_user_count': len(user_cache), + 'duration_ms': export_duration_ms, + 'has_search': bool(search_term), + 'activity_type_filter': activity_type_filter or 'all' + }, + debug_only=True, + category='CONTROL_CENTER' + ) + + return create_activity_log_csv_response(output.getvalue()) + + except Exception as ex: + log_event( + '[ControlCenter][ActivityLogs] Failed to export activity logs.', + extra={ + 'search': request.args.get('search', ''), + 'activity_type_filter': request.args.get('activity_type_filter', 'all'), + 'error_type': type(ex).__name__, + 'error_message': str(ex) + }, + level=logging.ERROR, + exceptionTraceback=True + ) + return jsonify({'error': 'Failed to export activity logs'}), 500 + # ============================================================================ # APPROVAL WORKFLOW ENDPOINTS # ============================================================================ diff --git a/application/single_app/route_enhanced_citations.py b/application/single_app/route_enhanced_citations.py index ca1b9e48..b3c90f96 100644 --- a/application/single_app/route_enhanced_citations.py +++ b/application/single_app/route_enhanced_citations.py @@ -3,16 +3,19 @@ from flask import jsonify, request, Response from datetime import datetime, timedelta +import logging import os import tempfile import requests import mimetypes import io import pandas +import fitz from functions_authentication import login_required, user_required, get_current_user_id +from functions_appinsights import log_event from functions_settings import get_settings, enabled_required -from functions_documents import get_document_metadata +from functions_documents import get_document_metadata, get_document_blob_storage_info from functions_group import get_user_groups from functions_public_workspaces import get_user_visible_public_workspace_ids_from_settings from swagger_wrapper import swagger_route, get_auth_security @@ -62,6 +65,28 @@ def _serialize_tabular_preview_table(df_preview): ] return columns, rows + +def _log_enhanced_citations_debug(message, **details): + """Write debug-gated enhanced citations diagnostics.""" + log_event( + f"[EnhancedCitations] {message}", + extra=details or None, + debug_only=True, + category="EnhancedCitations", + ) + + +def _log_enhanced_citations_error(message, error, **details): + """Write structured error diagnostics for enhanced citations failures.""" + error_details = dict(details) + error_details["error"] = str(error) + log_event( + f"[EnhancedCitations] {message}", + extra=error_details, + level=logging.ERROR, + exceptionTraceback=True, + ) + def register_enhanced_citations_routes(app): """Register enhanced citations routes""" @@ -234,7 +259,13 @@ def get_enhanced_citation_pdf(): if not doc_id: return jsonify({"error": "doc_id is required"}), 400 - debug_print(f"Enhanced citations PDF request - doc_id: {doc_id}, page: {page_number}, show_all: {show_all}") + _log_enhanced_citations_debug( + "PDF request received", + doc_id=doc_id, + page=page_number, + show_all=show_all, + download=download, + ) user_id = get_current_user_id() if not user_id: @@ -263,6 +294,14 @@ def get_enhanced_citation_pdf(): return serve_enhanced_citation_pdf_content(raw_doc, page_number, show_all) except Exception as e: + _log_enhanced_citations_error( + "PDF request failed", + e, + doc_id=doc_id, + page=page_number, + show_all=show_all, + download=download, + ) return jsonify({"error": str(e)}), 500 @app.route("/api/enhanced_citations/tabular", methods=["GET"]) @@ -607,35 +646,60 @@ def get_blob_name(raw_doc, workspace_type): """ _, blob_name = get_document_blob_storage_info(raw_doc) if blob_name: + _log_enhanced_citations_debug( + "Using stored blob path for citation content", + doc_id=raw_doc.get('id'), + workspace_type=workspace_type, + blob_name=blob_name, + ) return blob_name if workspace_type == 'public': - return f"{raw_doc['public_workspace_id']}/{raw_doc['file_name']}" + fallback_blob_name = f"{raw_doc['public_workspace_id']}/{raw_doc['file_name']}" elif workspace_type == 'group': - return f"{raw_doc['group_id']}/{raw_doc['file_name']}" + fallback_blob_name = f"{raw_doc['group_id']}/{raw_doc['file_name']}" else: - return f"{raw_doc['user_id']}/{raw_doc['file_name']}" + fallback_blob_name = f"{raw_doc['user_id']}/{raw_doc['file_name']}" + + _log_enhanced_citations_debug( + "Using legacy blob path fallback for citation content", + doc_id=raw_doc.get('id'), + workspace_type=workspace_type, + blob_name=fallback_blob_name, + ) + return fallback_blob_name def serve_enhanced_citation_content(raw_doc, content_type=None, force_download=False): """ Server-side rendering: Serve enhanced citation file content directly Based on the logic from the existing view_pdf function but serves content directly """ - settings = get_settings() - # Get blob storage client blob_service_client = CLIENTS.get("storage_account_office_docs_client") if not blob_service_client: raise Exception("Blob storage client not available") - - # Determine workspace type and container - workspace_type, container_name = determine_workspace_type_and_container(raw_doc) - container_client = blob_service_client.get_container_client(container_name) - - # Build blob name based on workspace type - blob_name = get_blob_name(raw_doc, workspace_type) - + + doc_id = raw_doc.get('id') + file_name = raw_doc.get('file_name') + workspace_type = None + container_name = None + blob_name = None + try: + workspace_type, container_name = determine_workspace_type_and_container(raw_doc) + blob_name = get_blob_name(raw_doc, workspace_type) + container_client = blob_service_client.get_container_client(container_name) + + _log_enhanced_citations_debug( + "Downloading citation content from blob storage", + doc_id=doc_id, + file_name=file_name, + workspace_type=workspace_type, + container_name=container_name, + blob_name=blob_name, + force_download=force_download, + ) + # Download blob content directly blob_client = container_client.get_blob_client(blob_name) blob_data = blob_client.download_blob() @@ -659,6 +723,18 @@ def serve_enhanced_citation_content(raw_doc, content_type=None, force_download=F content_type = 'audio/mpeg' else: content_type = 'application/octet-stream' + + _log_enhanced_citations_debug( + "Citation content downloaded successfully", + doc_id=doc_id, + file_name=file_name, + workspace_type=workspace_type, + container_name=container_name, + blob_name=blob_name, + content_type=content_type, + content_length=len(content), + force_download=force_download, + ) # Set content disposition based on force_download parameter disposition = 'attachment' if force_download else 'inline' @@ -678,8 +754,17 @@ def serve_enhanced_citation_content(raw_doc, content_type=None, force_download=F return response except Exception as e: - print(f"Error serving enhanced citation content: {e}") - raise Exception(f"Failed to load content: {str(e)}") + _log_enhanced_citations_error( + "Failed to serve citation content", + e, + doc_id=doc_id, + file_name=file_name, + workspace_type=workspace_type, + container_name=container_name, + blob_name=blob_name, + force_download=force_download, + ) + raise Exception(f"Failed to load content: {str(e)}") from e def serve_enhanced_citation_pdf_content(raw_doc, page_number, show_all=False): """ @@ -691,25 +776,40 @@ def serve_enhanced_citation_pdf_content(raw_doc, page_number, show_all=False): page_number: Current page number show_all: If True, show all pages instead of just ±1 pages around current """ - debug_print(f"serve_enhanced_citation_pdf_content called with show_all: {show_all}") - - import io - import uuid - import tempfile - import fitz # PyMuPDF + _log_enhanced_citations_debug( + "Preparing PDF citation content", + doc_id=raw_doc.get('id'), + file_name=raw_doc.get('file_name'), + page=page_number, + show_all=show_all, + ) blob_service_client = CLIENTS.get("storage_account_office_docs_client") if not blob_service_client: raise Exception("Blob storage client not available") - - # Determine workspace type and container - workspace_type, container_name = determine_workspace_type_and_container(raw_doc) - container_client = blob_service_client.get_container_client(container_name) - - # Build blob name based on workspace type - blob_name = get_blob_name(raw_doc, workspace_type) - + + doc_id = raw_doc.get('id') + file_name = raw_doc.get('file_name') + workspace_type = None + container_name = None + blob_name = None + try: + workspace_type, container_name = determine_workspace_type_and_container(raw_doc) + blob_name = get_blob_name(raw_doc, workspace_type) + container_client = blob_service_client.get_container_client(container_name) + + _log_enhanced_citations_debug( + "Downloading PDF citation blob", + doc_id=doc_id, + file_name=file_name, + workspace_type=workspace_type, + container_name=container_name, + blob_name=blob_name, + page=page_number, + show_all=show_all, + ) + # Download blob content directly blob_client = container_client.get_blob_client(blob_name) blob_data = blob_client.download_blob() @@ -727,6 +827,13 @@ def serve_enhanced_citation_pdf_content(raw_doc, page_number, show_all=False): current_idx = page_number - 1 # zero-based if current_idx < 0 or current_idx >= total_pages: + _log_enhanced_citations_debug( + "Requested PDF page was out of range", + doc_id=doc_id, + file_name=file_name, + page=page_number, + total_pages=total_pages, + ) pdf_document.close() os.remove(temp_pdf_path) return jsonify({"error": "Requested page out of range"}), 400 @@ -778,6 +885,19 @@ def serve_enhanced_citation_pdf_content(raw_doc, page_number, show_all=False): extracted_pdf.close() pdf_document.close() + _log_enhanced_citations_debug( + "Built PDF citation sub-document", + doc_id=doc_id, + file_name=file_name, + page=page_number, + show_all=show_all, + total_pages=total_pages, + start_idx=start_idx, + end_idx=end_idx, + viewer_page=new_page_number, + content_length=len(extracted_content), + ) + # Return the extracted PDF headers = { 'Content-Length': str(len(extracted_content)), @@ -789,7 +909,12 @@ def serve_enhanced_citation_pdf_content(raw_doc, page_number, show_all=False): # When show_all is True, allow iframe embedding if show_all: - debug_print(f"Setting CSP headers for iframe embedding (show_all={show_all})") + _log_enhanced_citations_debug( + "Setting CSP headers for iframe embedding", + doc_id=doc_id, + file_name=file_name, + show_all=show_all, + ) headers['Content-Security-Policy'] = ( "default-src 'self'; " "frame-ancestors 'self'; " # Allow embedding in same origin @@ -797,7 +922,12 @@ def serve_enhanced_citation_pdf_content(raw_doc, page_number, show_all=False): ) headers['X-Frame-Options'] = 'SAMEORIGIN' # Allow same-origin framing else: - debug_print(f"NOT setting CSP headers for iframe embedding (show_all={show_all})") + _log_enhanced_citations_debug( + "Skipping iframe embedding headers for sub-document response", + doc_id=doc_id, + file_name=file_name, + show_all=show_all, + ) response = Response( extracted_content, @@ -812,5 +942,15 @@ def serve_enhanced_citation_pdf_content(raw_doc, page_number, show_all=False): os.remove(temp_pdf_path) except Exception as e: - print(f"Error serving PDF citation content: {e}") - raise Exception(f"Failed to load PDF content: {str(e)}") + _log_enhanced_citations_error( + "Failed to serve PDF citation content", + e, + doc_id=doc_id, + file_name=file_name, + workspace_type=workspace_type, + container_name=container_name, + blob_name=blob_name, + page=page_number, + show_all=show_all, + ) + raise Exception(f"Failed to load PDF content: {str(e)}") from e diff --git a/application/single_app/route_external_health.py b/application/single_app/route_external_health.py index ce4508d0..3da829c3 100644 --- a/application/single_app/route_external_health.py +++ b/application/single_app/route_external_health.py @@ -16,4 +16,15 @@ def external_health_check(): """External health check endpoint for monitoring.""" now = datetime.now() time_string = now.strftime("%Y-%m-%d %H:%M:%S") - return time_string, 200 \ No newline at end of file + return time_string, 200 + + +def register_no_auth_health(app): + @app.route('/external/healthcheckz', methods=['GET']) + @swagger_route() + @enabled_required("enable_no_auth_external_healthcheck") + def no_auth_external_healthcheck(): + return { + "status": "ok", + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }, 200 \ No newline at end of file diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 0dc5cdd8..435134c6 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -154,6 +154,10 @@ def admin_settings(): # --- Add default for swagger documentation --- if 'enable_swagger' not in settings: settings['enable_swagger'] = True # Default enabled for development/testing + if 'enable_external_healthcheck' not in settings: + settings['enable_external_healthcheck'] = False + if 'enable_no_auth_external_healthcheck' not in settings: + settings['enable_no_auth_external_healthcheck'] = False if 'release_notifications_registered' not in settings: settings['release_notifications_registered'] = False if 'release_notifications_name' not in settings: @@ -709,7 +713,7 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul level=logging.INFO, ) log_general_admin_action( - admin_user_id=admin_user, + admin_user_id=user_id, admin_email=admin_email, action='Enabled and migrated multi-model endpoints', description=f'Migrated {len(migrated_models)} models to multi-endpoint configuration.' @@ -1101,6 +1105,7 @@ def is_valid_url(url): 'release_notifications_registered_at': form_data.get('release_notifications_registered_at', settings.get('release_notifications_registered_at', '')).strip(), 'release_notifications_updated_at': form_data.get('release_notifications_updated_at', settings.get('release_notifications_updated_at', '')).strip(), 'enable_external_healthcheck': form_data.get('enable_external_healthcheck') == 'on', + 'enable_no_auth_external_healthcheck': form_data.get('enable_no_auth_external_healthcheck') == 'on', 'enable_swagger': form_data.get('enable_swagger') == 'on', 'enable_semantic_kernel': form_data.get('enable_semantic_kernel') == 'on', 'per_user_semantic_kernel': form_data.get('per_user_semantic_kernel') == 'on', diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 14dcc0d3..7bdddda2 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -56,6 +56,7 @@ from functions_agent_payload import can_agent_use_default_multi_endpoint_model from semantic_kernel_plugins.plugin_loader import discover_plugins from semantic_kernel_plugins.openapi_plugin_factory import OpenApiPluginFactory +from config import cognitive_services_scope from functions_agent_scope import find_agent_by_scope, is_selected_agent_scope_enabled import app_settings_cache @@ -287,6 +288,9 @@ def resolve_authority(auth_settings): return custom_authority return AzureAuthorityHosts.AZURE_PUBLIC_CLOUD + def resolve_aoai_scope(): + return str(cognitive_services_scope or "").strip() + def resolve_foundry_scope(auth_settings, endpoint=None): custom_scope = (auth_settings.get("foundry_scope") or "").strip() if custom_scope: @@ -326,9 +330,10 @@ def build_token_provider(auth_settings, provider="aoai", endpoint=None): authority=authority, ) - scope = "https://cognitiveservices.azure.com/.default" if provider in ("aifoundry", "new_foundry"): scope = resolve_foundry_scope(auth_settings, endpoint=endpoint) + else: + scope = resolve_aoai_scope() return get_bearer_token_provider(credential, scope) diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 1ab7b282..6474185b 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -13,11 +13,67 @@ margin: 0 !important; } /* Fix for document dropdown visibility */ +#scope-dropdown .dropdown-menu.show, +#tags-dropdown .dropdown-menu.show, #document-dropdown .dropdown-menu.show { display: block !important; opacity: 1 !important; visibility: visible !important; - z-index: 1050 !important; /* Ensure it's above other elements */ + z-index: 1060 !important; /* Ensure it's above other elements */ +} + +.chat-shell-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.chat-shell-header-content { + gap: 0.25rem; + min-width: 0; +} + +.chat-shell-title-row { + gap: 0.5rem; + justify-content: space-between; + min-width: 0; +} + +.chat-shell-title-actions { + align-items: center; + display: flex; + flex: 0 0 auto; +} + +.chat-shell-icon-button { + align-items: center; + border-radius: 999px; + display: inline-flex; + height: 2.25rem; + justify-content: center; + padding: 0; + width: 2.25rem; +} + +.chat-shell-status-row { + min-height: 1.5rem; +} + +.chat-shell-classifications { + min-width: 0; +} + +#chat-sidebar-inline-toggle { + border-radius: 999px; + flex: 0 0 auto; + height: 2.25rem; + padding: 0; + width: 2.25rem; +} + +body.sidebar-collapsed #chat-sidebar-inline-toggle { + color: var(--bs-primary); } .chat-searchable-select .dropdown-menu.show { @@ -27,6 +83,10 @@ z-index: 1050 !important; } +.chat-toolbar-mobile-panel .chat-searchable-select .dropdown-menu.show { + z-index: 1060 !important; +} + /* Handle dropdown positioning at the edge of viewport */ #document-dropdown.dropup .dropdown-menu { bottom: 100% !important; @@ -46,21 +106,78 @@ right: auto !important; /* Prevent right positioning */ } +.chat-search-panel { + border-radius: 0.5rem; +} + +.chat-search-panel-grid { + align-items: flex-end; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.chat-search-panel-field { + min-width: 0; +} + +.chat-search-dropdown-button-content { + align-items: center; + display: inline-flex; + gap: 0.5rem; + min-width: 0; +} + +.chat-search-dropdown-loading-spinner { + flex: 0 0 auto; + height: 0.875rem; + width: 0.875rem; +} + +@media (min-width: 992px) { + .chat-search-panel .offcanvas-header { + display: none; + } +} + .chat-searchable-select { min-width: 120px; } .chat-toolbar { display: flex; - flex-wrap: nowrap; + flex-wrap: wrap; align-items: flex-end; gap: 0.75rem; } +.chat-toolbar-primary-row { + align-items: center; + display: flex; + flex: 1 1 auto; + min-width: 0; +} + +#chat-toolbar-desktop-tools-slot, +.chat-toolbar-selectors-slot { + display: flex; + min-width: 0; +} + +#chat-toolbar-desktop-tools-slot { + flex: 1 1 auto; +} + +.chat-toolbar-action-rail { + flex: 1 1 auto; + min-width: 0; +} + .chat-toolbar-actions, .chat-toolbar-controls, .chat-toolbar-toggles, -.chat-toolbar-selectors { +.chat-toolbar-selectors, +.chat-toolbar-secondary-panel { display: flex; align-items: center; gap: 0.5rem; @@ -69,7 +186,13 @@ .chat-toolbar-actions { flex: 1 1 auto; - flex-wrap: wrap; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 0.125rem; +} + +.chat-toolbar-actions .btn { + flex: 0 0 auto; } .chat-toolbar-controls { @@ -77,9 +200,57 @@ flex-wrap: nowrap; justify-content: flex-end; align-items: flex-end; + gap: 0.75rem; margin-left: auto; } +.chat-toolbar-primary-selector { + flex: 0 1 230px; + max-width: 230px; + min-width: 180px; +} + +.chat-toolbar-primary-selector .chat-toolbar-selector { + flex: 1 1 auto; + max-width: none; + min-width: 0; +} + +.chat-toolbar-primary-surface { + align-items: center; + display: flex; + flex: 1 1 auto; + min-width: 0; +} + +.chat-toolbar-primary-surface .chat-toolbar-selector { + flex: 1 1 auto; + max-width: none; + min-width: 0; +} + +.chat-toolbar-tools-surface { + flex: 1 1 auto; + flex-wrap: nowrap; + justify-content: flex-start; +} + +.chat-toolbar-selectors-slot { + flex: 0 1 auto; +} + +.chat-toolbar-mobile-panel { + --bs-offcanvas-height: min(26rem, 70vh); +} + +.chat-mobile-tools-toggle { + align-items: center; + border-radius: 999px; + flex: 0 0 auto; + gap: 0.35rem; + white-space: nowrap; +} + .chat-toolbar-toggles { flex: 0 0 auto; flex-wrap: nowrap; @@ -188,21 +359,22 @@ align-items: center; } - .chat-toolbar-actions { - flex: 1 1 100%; - } - .chat-toolbar-controls { flex: 1 1 100%; flex-wrap: wrap; - justify-content: flex-start; + justify-content: space-between; margin-left: 0; } + .chat-toolbar-primary-selector { + flex: 1 1 220px; + max-width: 280px; + } + .chat-toolbar-selectors { flex: 1 1 auto; flex-wrap: wrap; - justify-content: flex-start; + justify-content: flex-end; } .chat-toolbar-toggles { @@ -210,33 +382,202 @@ } } -@media (max-width: 768px) { - .chat-toolbar { +@media (min-width: 992px) { + .chat-toolbar-mobile-panel { + display: none !important; + } +} + +@media (max-width: 991.98px) { + .chat-search-panel { + border-radius: 0; + height: 100vh; + margin-bottom: 0 !important; + max-width: min(92vw, 24rem); + } + + .chat-search-panel .offcanvas-header { + border-bottom: 1px solid var(--bs-border-color); + } + + .chat-search-panel .offcanvas-body { + padding: 1rem; + } + + .chat-search-panel-mobile-footer { + align-items: center; + border-top: 1px solid var(--bs-border-color); + display: flex; + justify-content: flex-end; + padding: 0.75rem 1rem calc(env(safe-area-inset-bottom, 0px) + 1rem); + } + + .chat-search-panel-mobile-footer .btn-close { + margin-left: auto; + } + + .chat-search-panel-grid { + align-items: stretch; + display: grid; + gap: 0.75rem; + } + + #document-dropdown .dropdown-menu, + #scope-dropdown-menu, + #tags-dropdown-menu { + max-width: none; + min-width: 0; + width: 100% !important; + } + + .chat-shell-header { + padding: 0.875rem 1rem !important; + } + + .chat-shell-title-row { + align-items: flex-start; + } + + .chat-shell-status-row { flex-wrap: wrap; + } + + .chat-toolbar { align-items: center; + gap: 0.75rem; + } + + .chat-toolbar-primary-row, + #chat-toolbar-desktop-tools-slot, + #chat-toolbar-desktop-primary-slot, + #chat-toolbar-desktop-selectors-slot { + display: none; + } + + .chat-toolbar-mobile-panel.offcanvas-bottom { + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; + box-shadow: 0 -0.75rem 2rem rgba(0, 0, 0, 0.2); } .chat-toolbar-controls { - flex-basis: 100%; - flex-wrap: wrap; - justify-content: flex-start; + display: flex; + justify-content: flex-end; margin-left: 0; + width: 100%; } - .chat-toolbar-selectors { - flex-basis: 100%; - flex-wrap: wrap; + .chat-mobile-tools-toggle { + margin-left: auto; } - .chat-toolbar-toggles { + .chat-toolbar-mobile-panel .offcanvas-header { + border-bottom: 1px solid var(--bs-border-color); + padding: 1rem 1rem 0.875rem; + } + + .chat-toolbar-mobile-panel .offcanvas-body { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + } + + .chat-toolbar-mobile-tools-slot, + .chat-toolbar-mobile-primary-slot, + .chat-toolbar-mobile-selectors-slot { + width: 100%; + } + + .chat-toolbar-mobile-primary-slot .chat-toolbar-primary-surface { + width: 100%; + } + + .chat-toolbar-mobile-tools-slot .chat-toolbar-tools-surface { + align-items: stretch; + display: flex; + flex-direction: column; + gap: 1rem; + } + + .chat-toolbar-mobile-tools-slot .chat-toolbar-actions { + display: grid; + gap: 0.625rem; + grid-template-columns: minmax(0, 1fr); + overflow: visible; + padding-bottom: 0; + } + + .chat-toolbar-mobile-tools-slot .chat-toolbar-actions .btn { + align-items: center; + border-radius: 0.85rem; + display: flex; + gap: 0.625rem; + justify-content: flex-start; + min-height: 2.75rem; + padding: 0.75rem 1rem; width: 100%; + } + + .chat-toolbar-mobile-tools-slot .search-btn-text, + .chat-toolbar-mobile-tools-slot .file-btn-text { + display: inline !important; + margin-left: 0.375rem; + opacity: 1; + width: auto; + } + + .chat-toolbar-mobile-tools-slot .search-btn:not(.active) i, + .chat-toolbar-mobile-tools-slot .search-btn:not(.active) .search-btn-text, + .chat-toolbar-mobile-tools-slot .file-btn:not(.active) i, + .chat-toolbar-mobile-tools-slot .file-btn:not(.active) .file-btn-text { + color: var(--bs-emphasis-color); + } + + .chat-toolbar-mobile-tools-slot .chat-toolbar-toggles { flex-wrap: wrap; + justify-content: flex-start; + width: 100%; + } + + .chat-toolbar-mobile-selectors-slot .chat-toolbar-selectors { + align-items: stretch; + flex-direction: column; + width: 100%; } + .chat-toolbar-mobile-primary-slot .chat-toolbar-selector, + .chat-toolbar-mobile-selectors-slot .chat-toolbar-selector, .chat-toolbar-selector { flex: 1 1 100%; - min-width: 0; max-width: none; + min-width: 0; + width: 100%; + } + + #search-documents-container .flex-shrink-0, + #search-documents-container .flex-grow-1 { + max-width: none !important; + min-width: 0 !important; + width: 100%; + } + + #search-documents-container .dropdown, + #search-documents-container .form-select { + width: 100%; + } + + .chat-mobile-tools-toggle[aria-expanded="true"] { + background-color: rgba(var(--bs-primary-rgb), 0.08); + border-color: rgba(var(--bs-primary-rgb), 0.35); + color: var(--bs-primary); + } + + .chat-toolbar-actions, + .chat-toolbar-controls, + .chat-toolbar-selectors, + .chat-toolbar-toggles { + gap: 0.35rem; } } @@ -513,6 +854,11 @@ --bs-tooltip-zindex: 1115; } + #chat-toast-container { + top: 16px; + z-index: 1100; + } + .conversation-dropdown.tutorial-force-visible { opacity: 1 !important; } @@ -1288,6 +1634,22 @@ a.citation-link:hover { color: #000; /* Black text color */ } +@media (max-width: 991.98px) { + #chat-toolbar-mobile-tools-slot .search-btn .search-btn-text, + #chat-toolbar-mobile-tools-slot .file-btn .file-btn-text { + display: inline !important; + margin-left: 0.375rem; + opacity: 1 !important; + overflow: visible; + width: auto !important; + } + + #chat-toolbar-mobile-tools-slot .search-btn:not(.active) .search-btn-text, + #chat-toolbar-mobile-tools-slot .file-btn:not(.active) .file-btn-text { + color: var(--bs-emphasis-color); + } +} + /* Message container */ .message { display: flex; @@ -1665,6 +2027,13 @@ ol { /* Ensures elements align nicely at their bottom edges */ align-items: flex-end; } + +@media (max-width: 991.98px) { + .chat-input-container { + margin-top: 0.25rem; + } +} + #prompt-selection-container { align-self: auto; } diff --git a/application/single_app/static/css/navigation.css b/application/single_app/static/css/navigation.css index c561b19e..3b17d9dc 100644 --- a/application/single_app/static/css/navigation.css +++ b/application/single_app/static/css/navigation.css @@ -1,18 +1,212 @@ /* Top Navigation Styles */ +:root { + --top-nav-height: 66px; + --classification-banner-height: 40px; +} + /* Base body padding for top navigation */ body { - /* Default: navbar height only */ - padding-top: 56px; + padding-top: var(--top-nav-height); overflow-x: hidden; height: 100%; } +.top-nav-bar { + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); + box-shadow: 0 0.35rem 1.25rem rgba(15, 23, 42, 0.08); + z-index: 1046; +} + +.top-nav-shell { + align-items: center; + display: flex; + gap: 1rem; + min-height: var(--top-nav-height); +} + +.top-nav-brand { + flex: 0 1 auto; + min-width: 0; +} + +.top-nav-brand-link { + margin-right: 0; + max-width: 100%; +} + +.top-nav-brand-title { + display: inline-block; + max-width: min(24rem, 45vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.top-nav-primary-nav { + align-items: center; + flex-wrap: nowrap; + gap: 0.15rem; + min-width: 0; + overflow-x: auto; +} + +.top-nav-primary-nav .nav-link { + border-radius: 999px; + padding-left: 0.9rem; + padding-right: 0.9rem; + white-space: nowrap; +} + +.top-nav-chat-nav { + flex: 1 1 auto; +} + +.top-nav-chat-nav .nav-link { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.top-nav-actions { + align-items: center; + display: flex; + flex: 0 0 auto; + gap: 0.75rem; + margin-left: auto; +} + +.top-nav-mobile-toggle { + align-items: center; + border-radius: 999px; + display: inline-flex; + font-size: 1.15rem; + height: 2.5rem; + justify-content: center; + padding: 0; + width: 2.5rem; +} + +.top-nav-mobile-drawer { + max-width: min(88vw, 22rem); +} + +@media (max-width: 991.98px) { + .top-nav-mobile-drawer.offcanvas-start { + height: calc(100vh - var(--top-nav-height)); + top: var(--top-nav-height); + } + + body.has-classification-banner .top-nav-mobile-drawer.offcanvas-start { + height: calc(100vh - var(--top-nav-height) - var(--classification-banner-height)); + top: calc(var(--top-nav-height) + var(--classification-banner-height)); + } +} + +.top-nav-mobile-drawer .offcanvas-header { + border-bottom: 1px solid rgba(0, 0, 0, 0.08); +} + +.top-nav-mobile-section-label { + color: var(--bs-secondary-color, #6c757d); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + margin: 1rem 0 0.5rem; + text-transform: uppercase; +} + +.top-nav-mobile-section-label:first-child { + margin-top: 0; +} + +.top-nav-mobile-list .list-group-item { + align-items: center; + border: 0; + border-radius: 0.85rem; + display: flex; + font-weight: 500; + margin-bottom: 0.25rem; + padding: 0.85rem 0.95rem; +} + +.top-nav-mobile-list .list-group-item:hover, +.top-nav-mobile-list .list-group-item:focus-visible { + background: rgba(13, 110, 253, 0.08); +} + +.top-nav-user-nav { + align-items: center; + flex-direction: row; +} + +.top-nav-user-trigger { + align-items: center; + border-radius: 999px; + display: flex; + gap: 0.6rem; +} + +.top-nav-profile-avatar { + height: 28px; + position: relative; + width: 28px; +} + +.top-nav-profile-image { + height: 28px; + object-fit: cover; + width: 28px; +} + +.top-nav-initials { + font-size: 1rem; +} + +.top-nav-notification-badge { + border: 2px solid white; + bottom: -2px; + display: none; + font-size: 0.6rem; + font-weight: 700; + height: 18px; + line-height: 1; + padding: 0; + right: -2px; + width: 18px; +} + +.top-nav-user-meta { + flex-direction: column; + justify-content: center; + line-height: 1; + min-width: 0; +} + +.top-nav-user-name { + font-size: 1rem; +} + +.top-nav-user-role { + font-size: 0.75rem; +} + +.top-nav-user-menu { + min-width: 17rem; +} + +.top-nav-menu-section { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + /* Navigation layout toggle styles */ .nav-layout-toggle { + align-items: center; cursor: pointer; display: flex; - align-items: center; height: 100%; } @@ -22,27 +216,27 @@ body { /* Ensure nav-link containing nav layout toggle is aligned with other nav links */ .nav-item .nav-link.nav-layout-toggle { - display: flex; align-items: center; + display: flex; height: 100%; - padding-top: 0; padding-bottom: 0; padding-left: 1rem; padding-right: 1rem; + padding-top: 0; } /* Ensure dropdown nav layout toggle aligns with other dropdown items */ .dropdown-item.nav-layout-toggle { - display: flex; align-items: center; + display: flex; text-align: left; } /* Dark mode toggle styles within navigation */ .dark-mode-toggle { + align-items: center; cursor: pointer; display: flex; - align-items: center; height: 100%; } @@ -52,38 +246,84 @@ body { /* Ensure nav-link containing dark mode toggle is aligned with other nav links */ .nav-item .nav-link.dark-mode-toggle { - display: flex; align-items: center; + display: flex; height: 100%; - padding-top: 0; padding-bottom: 0; padding-left: 1rem; padding-right: 1rem; + padding-top: 0; } /* Ensure dropdown dark mode toggle aligns with other dropdown items */ .dropdown-item.dark-mode-toggle { - display: flex; align-items: center; + display: flex; text-align: left; } /* Classification banner adjustments */ #classification-banner + nav.navbar.fixed-top { - top: 40px; /* Height of the banner */ + top: var(--classification-banner-height); } #classification-banner { - height: 40px; - line-height: 40px; + height: var(--classification-banner-height); + line-height: var(--classification-banner-height); } /* Dark mode navbar styling */ [data-bs-theme="dark"] .navbar-light { - background-color: #343a40 !important; + background-color: rgba(33, 37, 41, 0.94) !important; } [data-bs-theme="dark"] .navbar-light .navbar-brand, [data-bs-theme="dark"] .navbar-light .nav-link { color: #e9ecef; } + +[data-bs-theme="dark"] .top-nav-mobile-drawer { + background: #212529; + color: #f8f9fa; +} + +[data-bs-theme="dark"] .top-nav-mobile-drawer .offcanvas-header { + border-bottom-color: rgba(255, 255, 255, 0.08); +} + +[data-bs-theme="dark"] .top-nav-mobile-list .list-group-item { + background: transparent; + color: #f8f9fa; +} + +[data-bs-theme="dark"] .top-nav-mobile-list .list-group-item:hover, +[data-bs-theme="dark"] .top-nav-mobile-list .list-group-item:focus-visible { + background: rgba(255, 255, 255, 0.08); +} + +@media (max-width: 991.98px) { + :root { + --top-nav-height: 64px; + } + + .top-nav-shell { + gap: 0.5rem; + } + + .top-nav-brand-link { + max-width: calc(100vw - 8.5rem); + } + + .top-nav-brand-title { + max-width: min(14rem, 54vw); + } + + .top-nav-user-trigger { + padding-left: 0.15rem; + padding-right: 0.15rem; + } + + .top-nav-user-menu { + min-width: 15rem; + } +} diff --git a/application/single_app/static/css/sidebar.css b/application/single_app/static/css/sidebar.css index 1b3f7eb8..ab7e652c 100644 --- a/application/single_app/static/css/sidebar.css +++ b/application/single_app/static/css/sidebar.css @@ -15,12 +15,21 @@ body.sidebar-nav-enabled { /* Sidebar container */ #sidebar-nav { display: none; + transition: transform 0.3s ease; +} + +#sidebar-nav.sidebar-collapsed { + transform: translateX(-100%); } body.sidebar-nav-enabled #sidebar-nav { display: flex !important; } +body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav { + display: flex !important; +} + /* Hide top navbar when sidebar is enabled */ body.sidebar-nav-enabled nav.navbar { display: none !important; @@ -53,14 +62,95 @@ body.has-classification-banner #sidebar-nav { /* Chats top-nav layout: align the fixed sidebar just below the navbar */ nav.navbar.fixed-top + #sidebar-nav { - top: 66px !important; - height: calc(100vh - 66px); + top: var(--top-nav-height) !important; + height: calc(100vh - var(--top-nav-height)); } /* Account for classification banner when present */ body.has-classification-banner nav.navbar + #sidebar-nav { - top: 98px !important; - height: calc(100vh - 98px); + top: calc(var(--top-nav-height) + var(--classification-banner-height)) !important; + height: calc(100vh - var(--top-nav-height) - var(--classification-banner-height)); +} + +body.chat-top-nav-shell { + --chat-sidebar-top: var(--top-nav-height); +} + +body.chat-top-nav-shell.has-classification-banner { + --chat-sidebar-top: calc(var(--top-nav-height) + var(--classification-banner-height)); +} + +body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav { + width: var(--sidebar-width, 260px); + min-width: var(--sidebar-min-width, 220px); +} + +body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav #sidebar-user-account { + min-width: 0; + width: 100%; +} + +body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav.show { + transform: none !important; +} + +body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav .offcanvas-header { + border-bottom: 1px solid var(--bs-border-color, #dee2e6); +} + +body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav .chat-sidebar-mobile-header { + display: none; +} + +body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav .chat-sidebar-user-account { + background-color: inherit; +} + +body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav .chat-sidebar-mobile-sections { + background-color: inherit; +} + +body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav .chat-sidebar-mobile-section + .chat-sidebar-mobile-section { + margin-top: 1rem; +} + +@media (min-width: 992px) { + body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav { + height: calc(100vh - var(--chat-sidebar-top)); + left: 0; + max-width: none; + position: fixed; + top: var(--chat-sidebar-top); + z-index: 1040; + } + + body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav .offcanvas-header { + display: none; + } +} + +@media (max-width: 991.98px) { + body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav { + height: calc(100vh - var(--chat-sidebar-top)); + max-width: min(88vw, 22rem); + top: var(--chat-sidebar-top); + z-index: 1045; + } + + body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav .chat-sidebar-mobile-header { + display: flex; + } + + body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav .sidebar-short-header, + body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav #sidebar-resize-handle { + display: none; + } + + body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav #sidebar-user-account { + -webkit-backdrop-filter: none; + backdrop-filter: none; + position: static; + } } /* Floating expand button positioning when classification banner is present */ @@ -68,6 +158,49 @@ body.has-classification-banner #floating-expand-btn { top: calc(0.5rem + 40px) !important; /* Start below the classification banner */ } +#floating-expand-btn { + position: fixed; + left: 0.5rem; + top: 0.5rem; + z-index: 1050; + min-height: 36px; + padding: 0.35rem 0.55rem; + border-radius: 999px; + align-items: center; + justify-content: center; + gap: 0.375rem; + background-color: var(--bs-body-bg, #fff) !important; + border: 1px solid var(--bs-border-color, #dee2e6) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important; + color: var(--bs-body-color, #212529) !important; +} + +#floating-expand-btn.sidebar-floating-expand-visible { + display: inline-flex !important; +} + +[data-bs-theme="dark"] #floating-expand-btn { + background-color: var(--bs-dark, #212529) !important; + border: 1px solid var(--bs-border-color-translucent, rgba(255, 255, 255, 0.15)) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5) !important; + color: var(--bs-body-color, #fff) !important; +} + +#floating-expand-btn:hover { + background-color: var(--bs-gray-100, #f8f9fa) !important; + transform: scale(1.05); + transition: all 0.2s ease; +} + +[data-bs-theme="dark"] #floating-expand-btn:hover { + background-color: var(--bs-gray-800, #343a40) !important; +} + +.floating-expand-label { + display: none; + font-weight: 600; +} + /* When the banner is present, add padding to the top of the main content */ body.sidebar-nav-enabled.has-classification-banner .main-content, body.sidebar-nav-enabled.has-classification-banner .container, @@ -75,6 +208,13 @@ body.sidebar-nav-enabled.has-classification-banner .container-fluid { padding-top: 2rem !important; /* Increased spacing when banner is present */ } +.main-content, +.container, +.container-fluid, +#main-content { + transition: margin-left 0.3s ease-in-out, max-width 0.3s ease-in-out !important; +} + /* Sidebar Conversations List Styles */ #conversations-section { display: flex; @@ -173,6 +313,56 @@ body.sidebar-nav-enabled.has-classification-banner .container-fluid { font-size: 0.9rem; } +.sidebar-header, +.sidebar-short-header { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sidebar-brand-link { + display: flex !important; + align-items: center; + gap: 0.75rem; + min-width: 0; + margin-bottom: 0 !important; +} + +.sidebar-brand-link img { + flex-shrink: 0; +} + +.sidebar-brand-text { + flex: 1 1 auto; + min-width: 0; +} + +.sidebar-title-truncate { + display: block; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; +} + +.sidebar-toggle-row { + display: flex; +} + +.sidebar-toggle-control { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 38px; + border-radius: 0.5rem; +} + +.sidebar-toggle-control i { + font-size: 0.95rem; +} + .sidebar-conversation-item { cursor: pointer; padding: 8px 12px; @@ -513,9 +703,48 @@ body.sidebar-nav-enabled.has-classification-banner .container-fluid { /* Ensure navbar-brand font size matches top nav */ #sidebar-nav .navbar-brand { font-size: 20px !important; /* Match top navbar font size */ + min-width: 0; } #sidebar-nav .navbar-brand .fw-bold, #sidebar-nav .navbar-brand span { font-size: 20px !important; /* Ensure child elements also use correct size */ +} + +#sidebar-user-account { + width: var(--sidebar-width, 260px); + min-width: var(--sidebar-min-width, 220px); + transition: inherit; + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + background-color: var(--bs-light, #f8f9fa) !important; +} + +[data-bs-theme="dark"] #sidebar-user-account { + background-color: var(--bs-dark, #212529) !important; + border-color: var(--bs-border-color-translucent, rgba(255,255,255,0.15)) !important; +} + +@media (max-width: 575.98px) { + #sidebar-nav { + max-width: calc(100vw - 0.75rem); + } + + #sidebar-user-account { + max-width: calc(100vw - 0.75rem); + } + + .sidebar-toggle-control { + min-height: 42px; + } + + #floating-expand-btn { + min-height: 42px; + padding: 0.5rem 0.75rem; + } + + .floating-expand-label { + display: inline; + font-size: 0.875rem; + } } \ No newline at end of file diff --git a/application/single_app/static/css/workspace-responsive.css b/application/single_app/static/css/workspace-responsive.css new file mode 100644 index 00000000..cf624e7f --- /dev/null +++ b/application/single_app/static/css/workspace-responsive.css @@ -0,0 +1,437 @@ +/* Shared responsive workspace styles */ + +.workspace-page { + padding-bottom: 2rem; +} + +.workspace-page-header { + align-items: flex-start; + display: flex; + gap: 1rem; + justify-content: space-between; + margin-bottom: 1rem; +} + +.workspace-page-title { + margin-bottom: 0; +} + +.workspace-page-subtitle { + color: var(--bs-secondary-color, #6c757d); + margin-bottom: 0; +} + +.workspace-section-switcher { + display: none; + flex: 0 0 clamp(13rem, 30vw, 18rem); + min-width: 13rem; +} + +.workspace-section-switcher--persistent { + display: flex; +} + +.workspace-section-switcher .form-label { + color: var(--bs-secondary-color, #6c757d); + font-size: 0.82rem; + font-weight: 600; + margin-bottom: 0.35rem; +} + +.workspace-page .nav-tabs { + flex-wrap: nowrap; + gap: 0.35rem; + margin-bottom: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} + +.workspace-page .nav-tabs .nav-link { + white-space: nowrap; +} + +.document-item-card { + border-color: rgba(15, 23, 42, 0.08); +} + +.document-item-card.is-selected { + border-color: rgba(13, 110, 253, 0.45); + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.12); +} + +.document-item-card__header { + align-items: flex-start; + display: flex; + gap: 0.75rem; + margin-bottom: 0.85rem; +} + +.document-item-card__check { + align-items: center; + display: flex; + justify-content: center; + padding-top: 0.15rem; +} + +.document-item-card__check .document-checkbox { + margin: 0; +} + +.document-item-card__title-wrap { + flex: 1 1 auto; + min-width: 0; +} + +.document-item-card__eyebrow { + color: var(--bs-secondary-color, #6c757d); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + margin-bottom: 0.2rem; + text-transform: uppercase; +} + +.document-item-card__subtitle { + color: var(--bs-secondary-color, #6c757d); + font-size: 0.8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.document-item-card__status { + flex: 0 0 auto; +} + +.document-item-card__summary { + color: var(--bs-secondary-color, #6c757d); + font-size: 0.8rem; + line-height: 1.45; + margin-bottom: 0.85rem; +} + +.document-item-card__meta { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 0.75rem; +} + +.document-meta-pill { + align-items: center; + background: rgba(13, 110, 253, 0.08); + border-radius: 999px; + color: var(--bs-body-color, #212529); + display: inline-flex; + font-size: 0.76rem; + font-weight: 500; + gap: 0.3rem; + padding: 0.28rem 0.6rem; +} + +.document-item-card__badges { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.document-item-card__tags { + margin-bottom: 0.85rem; +} + +.document-item-card__progress { + margin-bottom: 0.85rem; +} + +.document-item-card__progress-label { + color: var(--bs-secondary-color, #6c757d); + display: block; + font-size: 0.76rem; + margin-top: 0.35rem; + text-align: right; +} + +.document-item-card .item-card-buttons { + align-items: center; +} + +.document-item-card .item-card-buttons .btn { + align-items: center; + display: inline-flex; + justify-content: center; +} + +[data-bs-theme="dark"] .document-meta-pill { + background: rgba(110, 168, 254, 0.16); + color: #e9ecef; +} + +.workspace-toolbar-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.workspace-page .filter-buttons-col { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + +.workspace-page .action-dropdown .dropdown-toggle::after { + display: none; +} + +.workspace-page .table-loading-row td { + color: #6c757d; + padding: 1.5rem; + text-align: center; +} + +@media (max-width: 991.98px) { + .workspace-page-header { + flex-direction: column; + } + + .workspace-section-switcher { + display: flex; + min-width: 0; + width: 100%; + } + + .workspace-page .nav-tabs { + display: none !important; + } + + .workspace-toolbar-actions { + display: grid; + width: 100%; + } + + .workspace-toolbar-actions .btn { + width: 100%; + } + + .workspace-page .filter-buttons-col { + justify-content: stretch; + width: 100%; + } + + .workspace-page .filter-buttons-col .btn { + flex: 1 1 100%; + } + + #documents-table, + #group-documents-table, + #prompts-table, + #group-prompts-table, + #agents-table, + #group-agents-table, + #plugins-table, + #group-plugins-table { + table-layout: auto !important; + } + + #documents-table thead, + #group-documents-table thead, + #prompts-table thead, + #group-prompts-table thead, + #agents-table thead, + #group-agents-table thead, + #plugins-table thead, + #group-plugins-table thead { + display: none; + } + + #documents-table, + #group-documents-table, + #prompts-table, + #group-prompts-table, + #agents-table, + #group-agents-table, + #plugins-table, + #group-plugins-table, + #documents-table tbody, + #group-documents-table tbody, + #prompts-table tbody, + #group-prompts-table tbody, + #agents-table tbody, + #group-agents-table tbody, + #plugins-table tbody, + #group-plugins-table tbody { + display: block; + width: 100%; + } + + #documents-table tr.document-row, + #group-documents-table tr.document-row, + #prompts-table tbody tr, + #group-prompts-table tbody tr, + #agents-table tbody tr, + #group-agents-table tbody tr, + #plugins-table tbody tr, + #group-plugins-table tbody tr { + background: var(--bs-body-bg, #fff); + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 0.9rem; + box-shadow: 0 0.35rem 1rem rgba(15, 23, 42, 0.06); + display: block; + margin-bottom: 0.85rem; + overflow: hidden; + padding: 0.85rem 0.95rem; + } + + #documents-table tr.document-row td, + #group-documents-table tr.document-row td, + #prompts-table tbody tr td, + #group-prompts-table tbody tr td, + #agents-table tbody tr td, + #group-agents-table tbody tr td, + #plugins-table tbody tr td, + #group-plugins-table tbody tr td { + border: 0; + display: block; + max-width: none !important; + overflow: visible !important; + padding: 0.2rem 0; + text-align: left !important; + white-space: normal !important; + width: 100% !important; + } + + #documents-table tr.document-row td::before, + #group-documents-table tr.document-row td::before, + #prompts-table tbody tr td::before, + #group-prompts-table tbody tr td::before, + #agents-table tbody tr td::before, + #group-agents-table tbody tr td::before, + #plugins-table tbody tr td::before, + #group-plugins-table tbody tr td::before { + color: var(--bs-secondary-color, #6c757d); + display: block; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + margin-bottom: 0.15rem; + text-transform: uppercase; + } + + #documents-table tr.document-row td:nth-child(1)::before, + #group-documents-table tr.document-row td:nth-child(1)::before { + content: "Status"; + } + + #documents-table tr.document-row td:nth-child(2)::before, + #group-documents-table tr.document-row td:nth-child(2)::before { + content: "File"; + } + + #documents-table tr.document-row td:nth-child(3)::before, + #group-documents-table tr.document-row td:nth-child(3)::before { + content: "Title"; + } + + #documents-table tr.document-row td:nth-child(4)::before, + #group-documents-table tr.document-row td:nth-child(4)::before { + content: "Actions"; + } + + #documents-table tr.document-row td:nth-child(4), + #group-documents-table tr.document-row td:nth-child(4) { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.35rem; + padding-top: 0.55rem; + } + + #documents-table tr.document-row td:nth-child(4) .btn, + #group-documents-table tr.document-row td:nth-child(4) .btn { + flex: 1 1 8rem; + justify-content: center; + } + + #documents-table tr.document-row td:nth-child(4) .action-dropdown, + #group-documents-table tr.document-row td:nth-child(4) .action-dropdown { + margin-left: auto; + } + + #documents-table tr.document-details-row, + #documents-table tr.document-status-row, + #group-documents-table tr.document-details-row, + #group-documents-table tr.document-status-row { + display: block; + margin-bottom: 0.85rem; + margin-top: -0.55rem; + } + + #documents-table tr.document-details-row td, + #documents-table tr.document-status-row td, + #group-documents-table tr.document-details-row td, + #group-documents-table tr.document-status-row td { + background: rgba(13, 110, 253, 0.04); + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 0 0 0.9rem 0.9rem; + display: block; + padding: 0.85rem 0.95rem; + width: 100%; + } + + #prompts-table tbody tr td:nth-child(1)::before, + #group-prompts-table tbody tr td:nth-child(1)::before { + content: "Prompt"; + } + + #agents-table tbody tr td:nth-child(1)::before, + #group-agents-table tbody tr td:nth-child(1)::before { + content: "Name"; + } + + #plugins-table tbody tr td:nth-child(1)::before, + #group-plugins-table tbody tr td:nth-child(1)::before { + content: "Name"; + } + + #agents-table tbody tr td:nth-child(2)::before, + #group-agents-table tbody tr td:nth-child(2)::before, + #plugins-table tbody tr td:nth-child(2)::before, + #group-plugins-table tbody tr td:nth-child(2)::before { + content: "Description"; + } + + #prompts-table tbody tr td:nth-child(2)::before, + #group-prompts-table tbody tr td:nth-child(2)::before, + #agents-table tbody tr td:nth-child(3)::before, + #group-agents-table tbody tr td:nth-child(3)::before, + #plugins-table tbody tr td:nth-child(3)::before, + #group-plugins-table tbody tr td:nth-child(3)::before { + content: "Actions"; + } + + #prompts-table tbody tr td:nth-child(2), + #group-prompts-table tbody tr td:nth-child(2), + #agents-table tbody tr td:nth-child(3), + #group-agents-table tbody tr td:nth-child(3), + #plugins-table tbody tr td:nth-child(3), + #group-plugins-table tbody tr td:nth-child(3) { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.35rem; + padding-top: 0.45rem; + } + + #agents-table tbody tr td:nth-child(3) .btn, + #group-agents-table tbody tr td:nth-child(3) .btn, + #plugins-table tbody tr td:nth-child(3) .btn, + #group-plugins-table tbody tr td:nth-child(3) .btn, + #prompts-table tbody tr td:nth-child(2) .btn, + #group-prompts-table tbody tr td:nth-child(2) .btn { + flex: 1 1 7rem; + justify-content: center; + } +} \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-agents.js b/application/single_app/static/js/chat/chat-agents.js index 22dffc6c..8a67354c 100644 --- a/application/single_app/static/js/chat/chat-agents.js +++ b/application/single_app/static/js/chat/chat-agents.js @@ -22,12 +22,39 @@ const agentDropdownText = agentDropdownButton const agentSearchInput = document.getElementById('agent-search-input'); const agentDropdownItems = document.getElementById('agent-dropdown-items'); +const FLOATING_SELECTOR_DROPDOWN_CONFIG = { + boundary: 'viewport', + reference: 'toggle', + autoClose: 'outside', + popperConfig: { + strategy: 'fixed', + modifiers: [ + { + name: 'preventOverflow', + options: { + boundary: 'viewport', + padding: 12, + }, + }, + ], + }, +}; + let agentSelectorController = null; let scopeChangeListenerInitialized = false; let pendingScopeNarrowingAgent = null; let scopeClearActionInitialized = false; let dropdownHideListenerInitialized = false; +function notifyMobileSelectorActivated(selectorId, dropdownButtonId) { + window.dispatchEvent(new CustomEvent('chat:toolbar-selector-activated', { + detail: { + selectorId, + dropdownButtonId, + }, + })); +} + function initializeAgentSelector() { if (agentSelectorController || !agentSelect) { return agentSelectorController; @@ -44,6 +71,7 @@ function initializeAgentSelector() { placeholderText: 'Select an Agent', emptyMessage: 'No agents available', emptySearchMessage: 'No matching agents found', + dropdownConfig: FLOATING_SELECTOR_DROPDOWN_CONFIG, }); return agentSelectorController; @@ -440,6 +468,7 @@ export async function initializeAgentInteractions() { if (modelSelectContainer) modelSelectContainer.style.display = "none"; // Populate agent dropdown await populateAgentDropdown(); + notifyMobileSelectorActivated('agent-select-container', 'agent-dropdown-button'); } else { agentSelectContainer.style.display = "none"; if (modelSelectContainer) modelSelectContainer.style.display = "block"; diff --git a/application/single_app/static/js/chat/chat-conversation-info-button.js b/application/single_app/static/js/chat/chat-conversation-info-button.js index dfb5e0b7..4dcac532 100644 --- a/application/single_app/static/js/chat/chat-conversation-info-button.js +++ b/application/single_app/static/js/chat/chat-conversation-info-button.js @@ -1,4 +1,6 @@ // chat-conversation-info-button.js +// chat-conversation-info-button.js + /** * Module for handling the conversation info button in the title bar */ @@ -36,7 +38,8 @@ export function toggleConversationInfoButton(hasActiveConversation) { const infoButton = document.getElementById('conversation-info-btn'); if (infoButton) { - infoButton.style.display = hasActiveConversation ? 'inline-block' : 'none'; + infoButton.classList.toggle('d-none', !hasActiveConversation); + infoButton.setAttribute('aria-hidden', String(!hasActiveConversation)); } } diff --git a/application/single_app/static/js/chat/chat-documents.js b/application/single_app/static/js/chat/chat-documents.js index b1fab7f0..40be40e6 100644 --- a/application/single_app/static/js/chat/chat-documents.js +++ b/application/single_app/static/js/chat/chat-documents.js @@ -7,6 +7,7 @@ export const docScopeSelect = document.getElementById("doc-scope-select"); const searchDocumentsBtn = document.getElementById("search-documents-btn"); const docSelectEl = document.getElementById("document-select"); // Hidden select element const searchDocumentsContainer = document.getElementById("search-documents-container"); // Container for scope/doc/class +const searchDocumentsMobileClose = document.getElementById("search-documents-mobile-close"); // Custom dropdown elements const docDropdown = document.getElementById("document-dropdown"); @@ -22,6 +23,8 @@ const tagsDropdownButton = document.getElementById("tags-dropdown-button"); const tagsDropdownMenu = document.getElementById("tags-dropdown-menu"); const tagsDropdownItems = document.getElementById("tags-dropdown-items"); const tagsSearchInput = document.getElementById("tags-search-input"); +const tagsDropdownLoadingSpinner = document.getElementById("tags-dropdown-loading-spinner"); +const tagsDropdownText = tagsDropdownButton ? tagsDropdownButton.querySelector('.selected-tags-text') : null; // Scope dropdown elements const scopeDropdown = document.getElementById("scope-dropdown"); @@ -55,6 +58,8 @@ const publicWorkspaceIdToName = {}; let selectedPersonal = true; let selectedGroupIds = (window.userGroups || []).map(g => g.id); let selectedPublicWorkspaceIds = (window.userVisiblePublicWorkspaces || []).map(ws => ws.id); +let hasResolvedTagsState = false; +let tagsDropdownState = 'loading'; const documentSearchController = initializeFilterableDropdownSearch({ dropdownEl: docDropdown, @@ -83,6 +88,273 @@ const tagsSearchController = initializeFilterableDropdownSearch({ isAlwaysVisibleItem: item => item.getAttribute('data-search-role') === 'action', }); +const SEARCH_DOCUMENTS_MOBILE_MEDIA_QUERY = '(max-width: 991.98px)'; + +function isSearchDocumentsMobileDrawerViewport() { + return typeof window !== 'undefined' && window.matchMedia(SEARCH_DOCUMENTS_MOBILE_MEDIA_QUERY).matches; +} + +function setSearchDocumentsButtonActiveState(isActive) { + if (!searchDocumentsBtn) { + return; + } + + searchDocumentsBtn.classList.toggle('active', isActive); + searchDocumentsBtn.setAttribute('aria-expanded', String(isActive)); +} + +function setTagsDropdownButtonState({ state, message, enabled }) { + tagsDropdownState = state; + hasResolvedTagsState = state !== 'loading'; + + if (tagsDropdownButton) { + tagsDropdownButton.disabled = !enabled; + tagsDropdownButton.setAttribute('aria-disabled', String(!enabled)); + tagsDropdownButton.classList.toggle('is-loading', state === 'loading'); + tagsDropdownButton.classList.toggle('is-empty', state === 'empty'); + + if (!enabled && typeof bootstrap !== 'undefined' && bootstrap.Dropdown) { + bootstrap.Dropdown.getInstance(tagsDropdownButton)?.hide(); + } + } + + if (tagsDropdownLoadingSpinner) { + tagsDropdownLoadingSpinner.classList.toggle('d-none', state !== 'loading'); + } + + if (tagsDropdownText && typeof message === 'string') { + tagsDropdownText.textContent = message; + } +} + +function setTagsDropdownLoadingState(message = 'Loading tags...') { + tagsSearchController?.resetFilter(); + setTagsDropdownButtonState({ + state: 'loading', + message, + enabled: false, + }); +} + +function setTagsDropdownReadyState() { + setTagsDropdownButtonState({ + state: 'ready', + message: 'All Tags', + enabled: true, + }); + syncTagsDropdownButtonText(); +} + +function setTagsDropdownEmptyState(message = 'No tags available for this scope') { + tagsSearchController?.resetFilter(); + setTagsDropdownButtonState({ + state: 'empty', + message, + enabled: false, + }); +} + +function getSearchDocumentsOffcanvasInstance() { + if (!searchDocumentsContainer || !isSearchDocumentsMobileDrawerViewport()) { + return null; + } + + if (typeof bootstrap === 'undefined' || !bootstrap.Offcanvas) { + return null; + } + + return bootstrap.Offcanvas.getOrCreateInstance(searchDocumentsContainer, { toggle: false }); +} + +function closeSearchDocumentsDropdowns() { + if (typeof bootstrap === 'undefined' || !bootstrap.Dropdown) { + return; + } + + [scopeDropdownButton, tagsDropdownButton, docDropdownButton].forEach((buttonEl) => { + if (!buttonEl) { + return; + } + + bootstrap.Dropdown.getInstance(buttonEl)?.hide(); + }); +} + +function refreshDocumentsAndTags({ source = null, showLoading = true } = {}) { + if (showLoading) { + setTagsDropdownLoadingState(); + } + + return loadAllDocs() + .then(() => loadTagsForScope()) + .then(() => { + if (source) { + dispatchScopeChanged(source); + } + }); +} + +function getSearchDocumentsDropdownConfig() { + return { + boundary: 'viewport', + reference: 'toggle', + autoClose: 'outside', + popperConfig: { + strategy: 'fixed', + modifiers: [ + { + name: 'preventOverflow', + options: { + boundary: 'viewport', + padding: 10, + }, + }, + ], + }, + }; +} + +function sizeSearchFilterDropdown(buttonEl, menuEl, itemsContainerEl) { + if (!buttonEl || !menuEl) { + return; + } + + const fieldContainer = buttonEl.closest('.chat-search-panel-field'); + const containerWidth = fieldContainer ? fieldContainer.offsetWidth : buttonEl.offsetWidth || 280; + + menuEl.style.width = `${containerWidth}px`; + menuEl.style.maxWidth = `${containerWidth}px`; + menuEl.style.zIndex = '1060'; + + const menuRect = menuEl.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const maxPossibleHeight = Math.max(180, viewportHeight - menuRect.top - 10); + + menuEl.style.maxHeight = `${maxPossibleHeight}px`; + + if (!itemsContainerEl) { + return; + } + + const searchContainer = menuEl.querySelector('.chat-dropdown-search, .document-search-container'); + const searchHeight = searchContainer ? searchContainer.offsetHeight : 40; + itemsContainerEl.style.maxHeight = `${Math.max(120, maxPossibleHeight - searchHeight - 16)}px`; + itemsContainerEl.style.overflowY = 'auto'; +} + +function resetSearchFilterDropdownStyles(menuEl, itemsContainerEl) { + if (menuEl) { + menuEl.style.maxHeight = ''; + menuEl.style.maxWidth = ''; + menuEl.style.width = ''; + menuEl.style.zIndex = ''; + } + + if (itemsContainerEl) { + itemsContainerEl.style.maxHeight = ''; + itemsContainerEl.style.overflowY = ''; + } +} + +function initializeSearchFilterDropdown({ + dropdownEl, + buttonEl, + menuEl, + itemsContainerEl, + searchInputEl, + searchController, + onShown, +}) { + if (!dropdownEl || !buttonEl || !menuEl) { + return; + } + + new bootstrap.Dropdown(buttonEl, getSearchDocumentsDropdownConfig()); + + dropdownEl.addEventListener('show.bs.dropdown', function() { + if (searchInputEl) { + searchInputEl.value = ''; + } + + searchController?.applyFilter(''); + }); + + dropdownEl.addEventListener('shown.bs.dropdown', function() { + sizeSearchFilterDropdown(buttonEl, menuEl, itemsContainerEl); + onShown?.(); + + if (searchInputEl) { + setTimeout(() => searchInputEl.focus(), 50); + } + }); + + dropdownEl.addEventListener('hidden.bs.dropdown', function() { + searchController?.resetFilter(); + resetSearchFilterDropdownStyles(menuEl, itemsContainerEl); + }); +} + +export async function showSearchDocumentsPanel() { + if (!searchDocumentsContainer) { + return false; + } + + setSearchDocumentsButtonActiveState(true); + searchDocumentsContainer.style.display = 'block'; + + const offcanvasInstance = getSearchDocumentsOffcanvasInstance(); + if (!offcanvasInstance || searchDocumentsContainer.classList.contains('show')) { + return true; + } + + await new Promise((resolve) => { + searchDocumentsContainer.addEventListener('shown.bs.offcanvas', () => resolve(), { once: true }); + offcanvasInstance.show(); + }); + + return true; +} + +export function hideSearchDocumentsPanel() { + if (!searchDocumentsContainer) { + return false; + } + + closeSearchDocumentsDropdowns(); + + const offcanvasInstance = getSearchDocumentsOffcanvasInstance(); + if (offcanvasInstance && searchDocumentsContainer.classList.contains('show')) { + offcanvasInstance.hide(); + return true; + } + + setSearchDocumentsButtonActiveState(false); + searchDocumentsContainer.style.display = 'none'; + return true; +} + +if (searchDocumentsContainer) { + searchDocumentsContainer.addEventListener('shown.bs.offcanvas', () => { + setSearchDocumentsButtonActiveState(true); + }); + + searchDocumentsContainer.addEventListener('hidden.bs.offcanvas', () => { + closeSearchDocumentsDropdowns(); + + if (isSearchDocumentsMobileDrawerViewport()) { + searchDocumentsContainer.style.display = 'none'; + } + + setSearchDocumentsButtonActiveState(false); + }); +} + +searchDocumentsMobileClose?.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + hideSearchDocumentsPanel(); +}); + /* --------------------------------------------------------------------------- Get Effective Scopes — used by chat-messages.js and internally --------------------------------------------------------------------------- */ @@ -161,7 +433,7 @@ export async function toggleScopeLock(conversationId, newState) { updateHeaderLockIcon(); // Reload docs for the new scope - loadAllDocs().then(() => { loadTagsForScope(); }); + refreshDocumentsAndTags(); } /** @@ -180,7 +452,7 @@ export function restoreScopeLockState(lockState, contexts) { rebuildScopeDropdownWithLock(); // Reload docs for the locked scope - loadAllDocs().then(() => { loadTagsForScope(); }); + refreshDocumentsAndTags(); } else { // Not locked (null or false) — rebuild dropdown normally buildScopeDropdown(); @@ -218,7 +490,7 @@ export function resetScopeLock(options = {}) { updateHeaderLockIcon(); // Reload documents for the full "All" scope - loadAllDocs().then(() => { loadTagsForScope(); }); + refreshDocumentsAndTags(); } /* --------------------------------------------------------------------------- @@ -573,11 +845,7 @@ function dispatchScopeChanged(source = 'workspace') { } function runScopeRefreshPipeline(source = 'workspace') { - return loadAllDocs() - .then(() => loadTagsForScope()) - .then(() => { - dispatchScopeChanged(source); - }); + return refreshDocumentsAndTags({ source }); } export function setEffectiveScopes(nextScopes = {}, options = {}) { @@ -1155,8 +1423,6 @@ export async function loadTagsForScope() { const hasItems = allTags.length > 0 || classificationItems.length > 0; if (hasItems) { - showTagsDropdown(); - // Populate hidden select with tags and classifications allTags.forEach(tag => { const option = document.createElement('option'); @@ -1263,24 +1529,23 @@ export async function loadTagsForScope() { tagsSearchController?.applyFilter(tagsSearchInput ? tagsSearchInput.value : ''); } + + showTagsDropdown(); } else { hideTagsDropdown(); } } catch (error) { console.error('Error loading tags:', error); - hideTagsDropdown(); + hideTagsDropdown('Unable to load tags'); } } function showTagsDropdown() { - if (tagsDropdown) tagsDropdown.style.display = 'block'; + setTagsDropdownReadyState(); } -function hideTagsDropdown() { - if (tagsDropdown) tagsDropdown.style.display = 'none'; - if (tagsSearchController) { - tagsSearchController.resetFilter(); - } +function hideTagsDropdown(message = 'No tags available for this scope') { + setTagsDropdownEmptyState(message); } function resetTagSelectionState() { @@ -1305,7 +1570,7 @@ function resetTagSelectionState() { Sync Tags Dropdown Button Text with Selection State --------------------------------------------------------------------------- */ function syncTagsDropdownButtonText() { - if (!tagsDropdownButton || !tagsDropdownItems) return; + if (!tagsDropdownButton || !tagsDropdownItems || tagsDropdownState !== 'ready') return; const checkedItems = tagsDropdownItems.querySelectorAll('.tag-checkbox:checked'); const count = checkedItems.length; @@ -1329,8 +1594,7 @@ export async function ensureSearchDocumentsVisible() { return false; } - searchDocumentsBtn.classList.add('active'); - searchDocumentsContainer.style.display = 'block'; + await showSearchDocumentsPanel(); if (scopeLocked === true) { rebuildScopeDropdownWithLock(); @@ -1338,8 +1602,7 @@ export async function ensureSearchDocumentsVisible() { buildScopeDropdown(); } - await loadAllDocs(); - await loadTagsForScope(); + await refreshDocumentsAndTags({ showLoading: !hasResolvedTagsState }); try { const dropdownInstance = bootstrap.Dropdown.getInstance(docDropdownButton); @@ -1383,11 +1646,10 @@ export function openTagsDropdown() { return false; } - if (tagsDropdown.style.display === 'none' && (!tagsDropdownItems || !tagsDropdownItems.children.length)) { + if (tagsDropdownState !== 'ready' || !tagsDropdownItems || !tagsDropdownItems.children.length) { return false; } - showTagsDropdown(); return openDropdown(tagsDropdownButton); } @@ -1396,8 +1658,6 @@ export function openTagsDropdown() { --------------------------------------------------------------------------- */ export function getSelectedTags() { if (!chatTagsFilter) return []; - // Check if the tags dropdown is visible (the hidden select is always display:none via d-none class) - if (tagsDropdown && tagsDropdown.style.display === 'none') return []; return Array.from(chatTagsFilter.selectedOptions).map(opt => opt.value); } @@ -1604,14 +1864,12 @@ if (tagsDropdownItems) { if (searchDocumentsBtn) { searchDocumentsBtn.addEventListener("click", function () { - this.classList.toggle("active"); - if (!searchDocumentsContainer) return; if (this.classList.contains("active")) { - ensureSearchDocumentsVisible(); + hideSearchDocumentsPanel(); } else { - searchDocumentsContainer.style.display = "none"; + ensureSearchDocumentsVisible(); } }); } @@ -1716,8 +1974,13 @@ document.addEventListener('DOMContentLoaded', function() { try { const scopeDropdownEl = document.getElementById('scope-dropdown'); if (scopeDropdownEl) { - new bootstrap.Dropdown(scopeDropdownButton, { - autoClose: 'outside' + initializeSearchFilterDropdown({ + dropdownEl: scopeDropdownEl, + buttonEl: scopeDropdownButton, + menuEl: scopeDropdownMenu, + itemsContainerEl: scopeDropdownItems, + searchInputEl: scopeSearchInput, + searchController: scopeSearchController, }); } } catch (err) { @@ -1725,58 +1988,33 @@ document.addEventListener('DOMContentLoaded', function() { } } + if (tagsDropdown && tagsDropdownButton) { + try { + initializeSearchFilterDropdown({ + dropdownEl: tagsDropdown, + buttonEl: tagsDropdownButton, + menuEl: tagsDropdownMenu, + itemsContainerEl: tagsDropdownItems, + searchInputEl: tagsSearchInput, + searchController: tagsSearchController, + }); + } catch (err) { + console.error("Error initializing tags dropdown:", err); + } + } + // If search documents button exists, it needs to be clicked to show controls if (searchDocumentsBtn && docDropdownButton) { try { if (docDropdown) { - // Initialize Bootstrap dropdown with the right configuration - new bootstrap.Dropdown(docDropdownButton, { - boundary: 'viewport', - reference: 'toggle', - autoClose: 'outside', - popperConfig: { - strategy: 'fixed', - modifiers: [ - { - name: 'preventOverflow', - options: { - boundary: 'viewport', - padding: 10 - } - } - ] - } - }); - - // Clear search when opening - docDropdown.addEventListener('show.bs.dropdown', function() { - if (docSearchInput) { - docSearchInput.value = ''; - } - documentSearchController?.applyFilter(''); - }); - - // Adjust sizing and focus search when shown - docDropdown.addEventListener('shown.bs.dropdown', function() { - initializeDocumentDropdown(); - if (docSearchInput) { - setTimeout(() => docSearchInput.focus(), 50); - } - }); - - // Clean up inline styles and reset state when hidden - docDropdown.addEventListener('hidden.bs.dropdown', function() { - documentSearchController?.resetFilter(); - // Clear inline styles set by initializeDocumentDropdown so they - // don't interfere with Bootstrap's positioning on next open - if (docDropdownMenu) { - docDropdownMenu.style.maxHeight = ''; - docDropdownMenu.style.maxWidth = ''; - docDropdownMenu.style.width = ''; - } - if (docDropdownItems) { - docDropdownItems.style.maxHeight = ''; - } + initializeSearchFilterDropdown({ + dropdownEl: docDropdown, + buttonEl: docDropdownButton, + menuEl: docDropdownMenu, + itemsContainerEl: docDropdownItems, + searchInputEl: docSearchInput, + searchController: documentSearchController, + onShown: initializeDocumentDropdown, }); } } catch (err) { diff --git a/application/single_app/static/js/chat/chat-enhanced-citations.js b/application/single_app/static/js/chat/chat-enhanced-citations.js index 561a7831..789e7821 100644 --- a/application/single_app/static/js/chat/chat-enhanced-citations.js +++ b/application/single_app/static/js/chat/chat-enhanced-citations.js @@ -60,7 +60,7 @@ export async function showEnhancedCitationModal(docId, pageNumberOrTimestamp, ci showImageModal(docId, docMetadata.file_name); break; case 'pdf': - showPdfModal(docId, pageNumberOrTimestamp, citationId); + await showPdfModal(docId, pageNumberOrTimestamp, citationId); break; case 'video': // For video/audio files, pageNumberOrTimestamp is actually the chunk_sequence (seconds offset) @@ -127,13 +127,65 @@ export function showImageModal(docId, fileName) { modalInstance.show(); } +function fallBackToTextCitation(citationId) { + if (!citationId) { + return; + } + + import('./chat-citations.js').then(module => { + module.fetchCitedText(citationId); + }).catch(error => { + console.error('Failed to fall back to text citation:', error); + }); +} + +function revokePdfFrameObjectUrl(pdfFrame) { + const currentObjectUrl = pdfFrame.dataset.objectUrl; + if (!currentObjectUrl) { + return; + } + + URL.revokeObjectURL(currentObjectUrl); + delete pdfFrame.dataset.objectUrl; +} + +function bindPdfModalCleanup(pdfModal, pdfFrame) { + if (pdfModal.dataset.cleanupBound === 'true') { + return; + } + + pdfModal.addEventListener('hidden.bs.modal', () => { + revokePdfFrameObjectUrl(pdfFrame); + pdfFrame.removeAttribute('src'); + }); + pdfModal.dataset.cleanupBound = 'true'; +} + +async function getResponseErrorMessage(response, fallbackMessage) { + const contentType = response.headers.get('Content-Type') || ''; + + if (contentType.includes('application/json')) { + const errorData = await response.json().catch(() => null); + if (errorData && errorData.error) { + return errorData.error; + } + } + + const errorText = await response.text().catch(() => ''); + if (errorText) { + return errorText; + } + + return fallbackMessage; +} + /** * Show PDF modal using server-side rendering * @param {string} docId - Document ID * @param {string|number} pageNumber - Page number * @param {string} citationId - Citation ID for fallback */ -export function showPdfModal(docId, pageNumber, citationId) { +export async function showPdfModal(docId, pageNumber, citationId) { console.log(`Showing PDF modal for docId: ${docId}, page: ${pageNumber}`); showLoadingIndicator(); @@ -149,31 +201,53 @@ export function showPdfModal(docId, pageNumber, citationId) { const pdfFrame = pdfModal.querySelector('#pdfFrame'); const pdfTitle = pdfModal.querySelector('#pdfModalTitle'); - - // Set the PDF source directly to our server-side rendering endpoint - pdfFrame.src = pdfUrl; + bindPdfModalCleanup(pdfModal, pdfFrame); + revokePdfFrameObjectUrl(pdfFrame); + pdfFrame.removeAttribute('src'); pdfTitle.textContent = `PDF Document - Page ${pageNumber}`; - - // Handle loading and error states - pdfFrame.onload = function() { - hideLoadingIndicator(); - console.log('PDF loaded successfully'); - }; - - pdfFrame.onerror = function() { - hideLoadingIndicator(); - console.error('Failed to load PDF'); - showToast('Failed to load PDF document', 'error'); - - // Fall back to text citation - import('./chat-citations.js').then(module => { - module.fetchCitedText(citationId); + + try { + const response = await fetch(pdfUrl, { + credentials: 'same-origin', }); - }; - - // Show the modal - const modalInstance = new bootstrap.Modal(pdfModal); - modalInstance.show(); + + if (!response.ok) { + const errorMessage = await getResponseErrorMessage( + response, + `Failed to load PDF document (${response.status}).` + ); + throw new Error(errorMessage); + } + + const pdfBlob = await response.blob(); + const pdfObjectUrl = URL.createObjectURL(pdfBlob); + const viewerPage = response.headers.get('X-Sub-PDF-Page') || '1'; + + pdfFrame.dataset.objectUrl = pdfObjectUrl; + pdfFrame.onload = function() { + hideLoadingIndicator(); + console.log(`PDF loaded successfully for docId: ${docId}, page: ${pageNumber}`); + }; + + pdfFrame.onerror = function() { + hideLoadingIndicator(); + revokePdfFrameObjectUrl(pdfFrame); + console.error(`Failed to render PDF frame for docId: ${docId}, page: ${pageNumber}`); + showToast('Failed to render PDF document.', 'danger'); + fallBackToTextCitation(citationId); + }; + + pdfFrame.src = `${pdfObjectUrl}#page=${encodeURIComponent(viewerPage)}`; + + const modalInstance = bootstrap.Modal.getOrCreateInstance(pdfModal); + modalInstance.show(); + } catch (error) { + hideLoadingIndicator(); + revokePdfFrameObjectUrl(pdfFrame); + console.error('Failed to load PDF document:', error); + showToast(error.message || 'Failed to load PDF document.', 'danger'); + fallBackToTextCitation(citationId); + } } /** @@ -350,21 +424,10 @@ async function downloadTabularFile(downloadUrl, fallbackFilename, downloadBtn) { }); if (!response.ok) { - let errorMessage = `Could not download file (${response.status}).`; - const contentType = response.headers.get('Content-Type') || ''; - - if (contentType.includes('application/json')) { - const errorData = await response.json().catch(() => null); - if (errorData && errorData.error) { - errorMessage = errorData.error; - } - } else { - const errorText = await response.text().catch(() => ''); - if (errorText) { - errorMessage = errorText; - } - } - + const errorMessage = await getResponseErrorMessage( + response, + `Could not download file (${response.status}).` + ); throw new Error(errorMessage); } diff --git a/application/single_app/static/js/chat/chat-mobile-toolbar.js b/application/single_app/static/js/chat/chat-mobile-toolbar.js new file mode 100644 index 00000000..3bec4506 --- /dev/null +++ b/application/single_app/static/js/chat/chat-mobile-toolbar.js @@ -0,0 +1,163 @@ +// chat-mobile-toolbar.js + +function isMobileToolbarViewport() { + return window.matchMedia('(max-width: 991.98px)').matches; +} + +function getMobileToolsOffcanvas(panelElement) { + if (!panelElement || typeof bootstrap === 'undefined' || !bootstrap.Offcanvas) { + return null; + } + + return bootstrap.Offcanvas.getOrCreateInstance(panelElement, { toggle: false }); +} + +function hideMobileSelectorDropdown(dropdownButtonId) { + if (!dropdownButtonId || typeof bootstrap === 'undefined' || !bootstrap.Dropdown) { + return; + } + + const dropdownButton = document.getElementById(dropdownButtonId); + if (!dropdownButton) { + return; + } + + bootstrap.Dropdown.getInstance(dropdownButton)?.hide(); +} + +function closeOpenMobileSelectorDropdowns() { + hideMobileSelectorDropdown('model-dropdown-button'); + hideMobileSelectorDropdown('prompt-dropdown-button'); + hideMobileSelectorDropdown('agent-dropdown-button'); +} + +function revealSelectorInMobileDrawer({ selectorId, dropdownButtonId }) { + if (!isMobileToolbarViewport() || !selectorId) { + return; + } + + const selectorElement = document.getElementById(selectorId); + if (!selectorElement || window.getComputedStyle(selectorElement).display === 'none') { + return; + } + + selectorElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + + if (!dropdownButtonId || typeof bootstrap === 'undefined' || !bootstrap.Dropdown) { + return; + } + + const dropdownButton = document.getElementById(dropdownButtonId); + if (!dropdownButton) { + return; + } + + window.setTimeout(() => { + dropdownButton.focus({ preventScroll: true }); + bootstrap.Dropdown.getOrCreateInstance(dropdownButton, { + autoClose: 'outside', + }).show(); + }, 140); +} + +function moveSurfaceToSlot(surfaceElement, slotElement) { + if (!surfaceElement || !slotElement || surfaceElement.parentElement === slotElement) { + return; + } + + slotElement.appendChild(surfaceElement); +} + +function hideMobileToolsPanel(panelElement) { + if (!isMobileToolbarViewport()) { + return; + } + + getMobileToolsOffcanvas(panelElement)?.hide(); +} + +function initializeChatMobileToolbar() { + const mobileToolsToggle = document.getElementById('chat-mobile-tools-toggle'); + const mobileToolsClose = document.getElementById('chat-mobile-tools-close'); + const mobileToolsPanel = document.getElementById('chat-mobile-tools-panel'); + const primarySurface = document.getElementById('chat-toolbar-primary-surface'); + const toolsSurface = document.getElementById('chat-toolbar-tools-surface'); + const selectorsSurface = document.getElementById('chat-toolbar-selectors-surface'); + const desktopPrimarySlot = document.getElementById('chat-toolbar-desktop-primary-slot'); + const desktopToolsSlot = document.getElementById('chat-toolbar-desktop-tools-slot'); + const desktopSelectorsSlot = document.getElementById('chat-toolbar-desktop-selectors-slot'); + const mobileToolsSlot = document.getElementById('chat-toolbar-mobile-tools-slot'); + const mobilePrimarySlot = document.getElementById('chat-toolbar-mobile-primary-slot'); + const mobileSelectorsSlot = document.getElementById('chat-toolbar-mobile-selectors-slot'); + + if (!mobileToolsToggle || !mobileToolsPanel || !primarySurface || !toolsSurface || !selectorsSurface) { + return; + } + + const dismissButtonIds = [ + 'image-generate-btn', + 'search-documents-btn', + 'choose-file-btn', + 'search-web-btn', + 'reasoning-toggle-btn', + 'tts-autoplay-toggle-btn', + ]; + + const syncToolbarLayout = () => { + if (isMobileToolbarViewport()) { + moveSurfaceToSlot(primarySurface, mobilePrimarySlot); + moveSurfaceToSlot(toolsSurface, mobileToolsSlot); + moveSurfaceToSlot(selectorsSurface, mobileSelectorsSlot); + return; + } + + closeOpenMobileSelectorDropdowns(); + moveSurfaceToSlot(primarySurface, desktopPrimarySlot); + moveSurfaceToSlot(toolsSurface, desktopToolsSlot); + moveSurfaceToSlot(selectorsSurface, desktopSelectorsSlot); + + mobileToolsToggle.classList.remove('is-expanded'); + mobileToolsToggle.setAttribute('aria-expanded', 'false'); + getMobileToolsOffcanvas(mobileToolsPanel)?.hide(); + }; + + mobileToolsPanel.addEventListener('shown.bs.offcanvas', () => { + mobileToolsToggle.classList.add('is-expanded'); + mobileToolsToggle.setAttribute('aria-expanded', 'true'); + }); + + mobileToolsPanel.addEventListener('hidden.bs.offcanvas', () => { + closeOpenMobileSelectorDropdowns(); + mobileToolsToggle.classList.remove('is-expanded'); + mobileToolsToggle.setAttribute('aria-expanded', 'false'); + }); + + mobileToolsClose?.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + closeOpenMobileSelectorDropdowns(); + hideMobileToolsPanel(mobileToolsPanel); + }); + + window.addEventListener('chat:toolbar-selector-activated', (event) => { + revealSelectorInMobileDrawer(event.detail || {}); + }); + + dismissButtonIds.forEach((buttonId) => { + const button = document.getElementById(buttonId); + if (!button) { + return; + } + + button.addEventListener('click', () => { + window.setTimeout(() => { + hideMobileToolsPanel(mobileToolsPanel); + }, 0); + }); + }); + + window.addEventListener('resize', syncToolbarLayout); + syncToolbarLayout(); +} + +initializeChatMobileToolbar(); \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-model-selector.js b/application/single_app/static/js/chat/chat-model-selector.js index 3c4c5daa..9a3048a8 100644 --- a/application/single_app/static/js/chat/chat-model-selector.js +++ b/application/single_app/static/js/chat/chat-model-selector.js @@ -14,6 +14,24 @@ const modelDropdownText = modelDropdownButton const modelSearchInput = document.getElementById('model-search-input'); const modelDropdownItems = document.getElementById('model-dropdown-items'); +const FLOATING_SELECTOR_DROPDOWN_CONFIG = { + boundary: 'viewport', + reference: 'toggle', + autoClose: 'outside', + popperConfig: { + strategy: 'fixed', + modifiers: [ + { + name: 'preventOverflow', + options: { + boundary: 'viewport', + padding: 12, + }, + }, + ], + }, +}; + let modelSelectorController = null; let scopeChangeListenerInitialized = false; let suppressScopeNarrowing = false; @@ -484,6 +502,7 @@ export function initializeModelSelector() { emptyMessage: 'No models available', emptySearchMessage: 'No matching models found', getOptionSearchText: option => option.dataset.searchText || option.textContent.trim(), + dropdownConfig: FLOATING_SELECTOR_DROPDOWN_CONFIG, }); } diff --git a/application/single_app/static/js/chat/chat-onload.js b/application/single_app/static/js/chat/chat-onload.js index 16de2096..77d329e2 100644 --- a/application/single_app/static/js/chat/chat-onload.js +++ b/application/single_app/static/js/chat/chat-onload.js @@ -10,6 +10,7 @@ import { filterDocumentsBySelectedTags, setScopeFromUrlParam, ensureSearchDocumentsVisible, + showSearchDocumentsPanel, openScopeDropdown, openTagsDropdown, } from "./chat-documents.js"; @@ -204,13 +205,12 @@ window.addEventListener('DOMContentLoaded', async () => { }) }) .then(response => response.json()) - .then(data => { + .then(async data => { if (data.message) { console.log('Active public workspace set successfully'); // Auto-open search documents section - localSearchDocsBtn.classList.add("active"); - searchDocumentsContainer.style.display = "block"; + await showSearchDocumentsPanel(); // Set scope to public setScopeFromUrlParam("public", { workspaceId: workspaceParam }); @@ -238,8 +238,7 @@ window.addEventListener('DOMContentLoaded', async () => { }); } else if (localSearchDocsParam && localSearchDocsBtn && localDocScopeSel && localDocSelectEl && searchDocumentsContainer) { console.log("Handling document URL parameters."); // Log - localSearchDocsBtn.classList.add("active"); - searchDocumentsContainer.style.display = "block"; + await showSearchDocumentsPanel(); if (localDocScopeParam) { setScopeFromUrlParam(localDocScopeParam, { groupId: groupIdParam, workspaceId: workspaceIdParam }); } @@ -342,8 +341,7 @@ window.addEventListener('DOMContentLoaded', async () => { } } else if (openSearchParam && scopeParam === "public" && localSearchDocsBtn && localDocScopeSel && searchDocumentsContainer) { // Handle openSearch=1&scope=public from public directory chat button - localSearchDocsBtn.classList.add("active"); - searchDocumentsContainer.style.display = "block"; + await showSearchDocumentsPanel(); setScopeFromUrlParam("public"); populateDocumentSelectScope(); loadTagsForScope(); diff --git a/application/single_app/static/js/chat/chat-prompts.js b/application/single_app/static/js/chat/chat-prompts.js index 828a5dbd..54f656a8 100644 --- a/application/single_app/static/js/chat/chat-prompts.js +++ b/application/single_app/static/js/chat/chat-prompts.js @@ -24,6 +24,15 @@ let userPrompts = []; let groupPrompts = []; let publicPrompts = []; +function notifyMobileSelectorActivated(selectorId, dropdownButtonId) { + window.dispatchEvent(new CustomEvent("chat:toolbar-selector-activated", { + detail: { + selectorId, + dropdownButtonId, + }, + })); +} + function getPreloadedPromptOptions() { return Array.isArray(window.chatPromptOptions) ? window.chatPromptOptions : []; } @@ -242,7 +251,9 @@ export function initializePromptInteractions() { if (isActive) { promptSelectionContainer.style.display = "block"; - loadAllPrompts(); + loadAllPrompts().finally(() => { + notifyMobileSelectorActivated("prompt-selection-container", "prompt-dropdown-button"); + }); userInput.classList.add("with-prompt-active"); userInput.focus(); updateSendButtonVisibility(); diff --git a/application/single_app/static/js/chat/chat-searchable-select.js b/application/single_app/static/js/chat/chat-searchable-select.js index c8acbcfd..51990190 100644 --- a/application/single_app/static/js/chat/chat-searchable-select.js +++ b/application/single_app/static/js/chat/chat-searchable-select.js @@ -203,6 +203,7 @@ export function createSearchableSingleSelect({ emptySearchMessage, getOptionLabel, getOptionSearchText, + dropdownConfig, }) { if (!selectEl || !dropdownEl || !buttonEl || !buttonTextEl || !menuEl || !searchInputEl || !itemsContainerEl) { return null; @@ -210,6 +211,9 @@ export function createSearchableSingleSelect({ const readOptionLabel = getOptionLabel || (option => option.textContent.trim()); const readOptionSearchText = getOptionSearchText || (option => option.textContent.trim()); + const resolvedDropdownConfig = dropdownConfig || { + autoClose: 'outside', + }; const getTopLevelEntries = () => Array.from(selectEl.children).filter(child => { const tagName = child.tagName; @@ -339,9 +343,7 @@ export function createSearchableSingleSelect({ selectEl.dispatchEvent(new Event('change', { bubbles: true })); try { - bootstrap.Dropdown.getOrCreateInstance(buttonEl, { - autoClose: 'outside' - }).hide(); + bootstrap.Dropdown.getOrCreateInstance(buttonEl, resolvedDropdownConfig).hide(); } catch (error) { console.error('Error hiding dropdown after selection:', error); } @@ -405,9 +407,7 @@ export function createSearchableSingleSelect({ }); try { - bootstrap.Dropdown.getOrCreateInstance(buttonEl, { - autoClose: 'outside' - }); + bootstrap.Dropdown.getOrCreateInstance(buttonEl, resolvedDropdownConfig); } catch (error) { console.error('Error initializing searchable select:', error); } diff --git a/application/single_app/static/js/chat/chat-toast.js b/application/single_app/static/js/chat/chat-toast.js index f255bc23..6b255575 100644 --- a/application/single_app/static/js/chat/chat-toast.js +++ b/application/single_app/static/js/chat/chat-toast.js @@ -1,8 +1,82 @@ // chat-toast.js +const preferredToastContainerSelector = '[data-toast-container="preferred"]'; +const syncedToastContainers = new WeakSet(); + +function getToastContainer() { + return document.querySelector(preferredToastContainerSelector) || document.getElementById("toast-container"); +} + +function getToastAnchor(container) { + const anchorId = container?.dataset.toastAnchor; + if (!anchorId) { + return null; + } + + return document.getElementById(anchorId); +} + +function syncToastContainerPosition(container) { + if (!container) { + return; + } + + if (!container.dataset.toastAnchor) { + return; + } + + const defaultTop = container.dataset.toastDefaultTop || "16px"; + const anchor = getToastAnchor(container); + + if (!anchor || anchor.offsetParent === null || !anchor.classList.contains("is-ready")) { + container.style.top = defaultTop; + return; + } + + const gap = Number.parseInt(container.dataset.toastGap || "12", 10); + const containerPaddingTop = Number.parseFloat(window.getComputedStyle(container).paddingTop || "0"); + const anchorRect = anchor.getBoundingClientRect(); + const anchoredTop = Math.max(16, Math.ceil(anchorRect.bottom + gap - containerPaddingTop)); + + container.style.top = `${anchoredTop}px`; +} + +function ensureToastContainerAnchorSync(container) { + if (!container || !container.dataset.toastAnchor || syncedToastContainers.has(container)) { + return; + } + + syncedToastContainers.add(container); + + const reposition = () => syncToastContainerPosition(container); + const anchor = getToastAnchor(container); + + window.addEventListener("resize", reposition); + + if (window.ResizeObserver && anchor) { + const resizeObserver = new ResizeObserver(reposition); + resizeObserver.observe(anchor); + } + + if (window.MutationObserver && anchor) { + const mutationObserver = new MutationObserver(reposition); + mutationObserver.observe(anchor, { + attributes: true, + attributeFilter: ["class", "style"], + }); + } + + reposition(); +} + export function showToast(message, variant = "danger") { - const container = document.getElementById("toast-container"); - if (!container) return; + const container = getToastContainer(); + if (!container) { + return; + } + + ensureToastContainerAnchorSync(container); + syncToastContainerPosition(container); const id = "toast-" + Date.now(); const toastEl = document.createElement("div"); diff --git a/application/single_app/static/js/control-center.js b/application/single_app/static/js/control-center.js index 639e9466..1c8a02d7 100644 --- a/application/single_app/static/js/control-center.js +++ b/application/single_app/static/js/control-center.js @@ -23,6 +23,14 @@ function parseDateKey(dateStr) { return Number.isNaN(parsed.getTime()) ? null : parsed; } +const ACTIVITY_LOGS_LAYOUT_PRESET_STORAGE_KEY = 'simplechat_activityLogsLayoutPreset'; +const ACTIVITY_LOGS_LAYOUT_PRESETS = ['balanced', 'details-focus', 'compact']; +const ACTIVITY_LOGS_LAYOUT_HINTS = { + balanced: 'Balanced view keeps the five log columns readable. Switch to Details Focus or click a row for the full raw log.', + 'details-focus': 'Details Focus widens the Details column for longer entries. Click a row for the full raw log.', + compact: 'Compact view prioritizes faster scanning. Click a row for the full raw log when details are truncated.' +}; + // Group Table Sorter - similar to user table but for groups class GroupTableSorter { constructor(tableId) { @@ -178,12 +186,17 @@ class ControlCenter { this.activityLogsPerPage = 50; this.activityLogsSearch = ''; this.activityTypeFilter = 'all'; + this.activityLogsLayoutPreset = 'balanced'; + this.currentActivityLogUserMap = {}; + this.currentRawLogJson = ''; this.init(); } init() { this.bindEvents(); + this.loadActivityLogsLayoutPreset(); + this.applyActivityLogsLayoutPreset(this.activityLogsLayoutPreset); // Check if user has admin role (passed from backend) const hasAdminRole = window.hasControlCenterAdmin === true; @@ -219,17 +232,8 @@ class ControlCenter { setTimeout(() => this.loadPublicWorkspaces(), 100); }); - document.getElementById('activity-logs-tab')?.addEventListener('click', () => { - console.log('Activity Logs tab clicked!'); - setTimeout(() => { - console.log('Calling loadActivityLogs...'); - this.loadActivityLogs(); - }, 100); - }); - - // Also use shown.bs.tab as backup document.getElementById('activity-logs-tab')?.addEventListener('shown.bs.tab', () => { - console.log('Activity Logs tab shown event fired'); + this.loadActivityLogs(); }); // Search and filter controls @@ -361,6 +365,9 @@ class ControlCenter { () => this.exportActivityLogsToCSV()); document.getElementById('refreshActivityLogsBtn')?.addEventListener('click', () => this.loadActivityLogs()); + document.querySelectorAll('input[name="activityLogsLayoutPreset"]').forEach((presetInput) => { + presetInput.addEventListener('change', (event) => this.handleActivityLogsLayoutPresetChange(event)); + }); } debounce(func, wait) { @@ -2418,16 +2425,82 @@ class ControlCenter { } // Activity Logs Methods - async loadActivityLogs() { - console.log('=== loadActivityLogs CALLED ==='); - console.log('this:', this); - console.log('State:', { - activityLogsPage: this.activityLogsPage, - activityLogsPerPage: this.activityLogsPerPage, - activityLogsSearch: this.activityLogsSearch, - activityTypeFilter: this.activityTypeFilter + getDefaultActivityLogsLayoutPreset() { + return 'balanced'; + } + + isValidActivityLogsLayoutPreset(preset) { + return ACTIVITY_LOGS_LAYOUT_PRESETS.includes(preset); + } + + loadActivityLogsLayoutPreset() { + let storedPreset = null; + + try { + storedPreset = window.localStorage.getItem(ACTIVITY_LOGS_LAYOUT_PRESET_STORAGE_KEY); + } catch (error) { + console.warn('Unable to load Activity Logs layout preset:', error); + } + + if (this.isValidActivityLogsLayoutPreset(storedPreset)) { + this.activityLogsLayoutPreset = storedPreset; + return; + } + + this.activityLogsLayoutPreset = this.getDefaultActivityLogsLayoutPreset(); + } + + saveActivityLogsLayoutPreset() { + try { + window.localStorage.setItem(ACTIVITY_LOGS_LAYOUT_PRESET_STORAGE_KEY, this.activityLogsLayoutPreset); + } catch (error) { + console.warn('Unable to save Activity Logs layout preset:', error); + } + } + + syncActivityLogsLayoutPresetControls() { + document.querySelectorAll('input[name="activityLogsLayoutPreset"]').forEach((presetInput) => { + presetInput.checked = presetInput.value === this.activityLogsLayoutPreset; }); - + } + + updateActivityLogsLayoutHint() { + const hintElement = document.getElementById('activityLogsLayoutHint'); + if (!hintElement) { + return; + } + + hintElement.textContent = ACTIVITY_LOGS_LAYOUT_HINTS[this.activityLogsLayoutPreset] + || ACTIVITY_LOGS_LAYOUT_HINTS[this.getDefaultActivityLogsLayoutPreset()]; + } + + applyActivityLogsLayoutPreset(preset) { + const resolvedPreset = this.isValidActivityLogsLayoutPreset(preset) + ? preset + : this.getDefaultActivityLogsLayoutPreset(); + + this.activityLogsLayoutPreset = resolvedPreset; + + const activityLogsTable = document.getElementById('activityLogsTable'); + if (activityLogsTable) { + activityLogsTable.setAttribute('data-layout-preset', resolvedPreset); + } + + this.syncActivityLogsLayoutPresetControls(); + this.updateActivityLogsLayoutHint(); + } + + handleActivityLogsLayoutPresetChange(event) { + const preset = event.target?.value; + if (!this.isValidActivityLogsLayoutPreset(preset)) { + return; + } + + this.applyActivityLogsLayoutPreset(preset); + this.saveActivityLogsLayoutPreset(); + } + + async loadActivityLogs() { try { const params = new URLSearchParams({ page: this.activityLogsPage, @@ -2436,31 +2509,31 @@ class ControlCenter { activity_type_filter: this.activityTypeFilter }); - const url = `/api/admin/control-center/activity-logs?${params}`; - console.log('Fetching from:', url); - - const response = await fetch(url); - console.log('Response received:', response.status); + const response = await fetch(`/api/admin/control-center/activity-logs?${params}`); + let data = null; + try { + data = await response.json(); + } catch (parseError) { + data = null; + } if (!response.ok) { - throw new Error('Failed to load activity logs'); + throw new Error(data?.error || 'Failed to load activity logs'); } - - const data = await response.json(); - console.log('Activity logs loaded:', data); this.renderActivityLogs(data.logs, data.user_map); this.renderActivityLogsPagination(data.pagination); } catch (error) { console.error('Error loading activity logs:', error); - this.showActivityLogsError('Failed to load activity logs. Please try again.'); + this.showActivityLogsError(error.message || 'Failed to load activity logs. Please try again.'); } } - renderActivityLogs(logs, userMap) { + renderActivityLogs(logs, userMap = {}) { // Store logs for modal access this.currentActivityLogs = logs; + this.currentActivityLogUserMap = userMap; const tbody = document.getElementById('activityLogsTableBody'); if (!tbody) return; @@ -2476,7 +2549,7 @@ class ControlCenter { return; } - tbody.innerHTML = logs.map(log => { + tbody.innerHTML = logs.map((log, logIndex) => { // Handle user identification - some activities may not have user_id (system activities) let userName = 'System'; if (log.user_id) { @@ -2490,24 +2563,35 @@ class ControlCenter { userName = log.added_by_email; } - const timestamp = new Date(log.timestamp).toLocaleString(); + const timestamp = this.formatActivityLogTimestamp(log.timestamp); const activityType = this.formatActivityType(log.activity_type); const details = this.formatActivityDetails(log); const workspaceType = log.workspace_type || 'N/A'; - - const logIndex = logs.indexOf(log); return ` -
${escapedValue}`;
+ }
+
+ if (field.badgeClass) {
+ return `${escapedValue}`;
+ }
+
+ return escapedValue;
+ }
+
+ renderActivityLogMetricGrid(fields) {
+ const visibleFields = fields.filter((field) => field && this.isActivityLogValuePresent(field.value ?? field.html));
+
+ if (!visibleFields.length) {
+ return '${this.escapeHtml(prettyJson)}
+ ${this.escapeHtml(JSON.stringify(log, null, 2))}`;
+ this.currentRawLogJson = JSON.stringify(log, null, 2);
+ modalBody.innerHTML = this.renderActivityLogModal(log, this.currentRawLogJson);
- // Show modal
const modal = new bootstrap.Modal(document.getElementById('rawLogModal'));
modal.show();
}
copyRawLogToClipboard() {
- const rawLogText = document.getElementById('rawLogModalBody')?.textContent;
+ const rawLogText = this.currentRawLogJson || document.getElementById('rawLogModalJson')?.textContent;
if (!rawLogText) {
showToast('No log data to copy', 'warning');
return;
}
navigator.clipboard.writeText(rawLogText).then(() => {
- this.showToast('Log data copied to clipboard', 'success');
+ this.showToast('Log JSON copied to clipboard', 'success');
}).catch(err => {
console.error('Failed to copy:', err);
showToast('Failed to copy to clipboard', 'danger');
diff --git a/application/single_app/static/js/navigation.js b/application/single_app/static/js/navigation.js
index be624c2e..40116f89 100644
--- a/application/single_app/static/js/navigation.js
+++ b/application/single_app/static/js/navigation.js
@@ -1,95 +1,142 @@
-// Top Navigation Functionality
+// navigation.js
/**
- * Navigation-related utilities and event handlers
- * Handles general navigation behavior and interactions
+ * Navigation-related utilities and event handlers.
+ * Handles responsive drawer behavior, dropdown accessibility, and overlay coordination.
*/
-// Initialize top navigation functionality
document.addEventListener('DOMContentLoaded', () => {
- // Set up any top navigation specific event listeners
- console.log('Top navigation initialized');
+ handleResponsiveNavigation();
+ setupDropdownBehaviors();
+ initializeMobileNavigationDrawers();
+ initializeNavigationOverlayCoordination();
+});
- // Handle responsive navigation behavior
- handleResponsiveNavigation();
+function isChatRailNavigationDrawer(offcanvasElement) {
+ return offcanvasElement?.dataset?.navigationDrawer === 'chat-rail';
+}
- // Set up dropdown behaviors
- setupDropdownBehaviors();
-});
+function getNavigationOffcanvasElements() {
+ if (typeof bootstrap === 'undefined' || !bootstrap.Offcanvas) {
+ return [];
+ }
-// Handle responsive navigation behavior
-function handleResponsiveNavigation() {
- // Handle window resize for responsive navigation
- window.addEventListener('resize', function() {
- // Close any open mobile menus when resizing to larger screens
- if (window.innerWidth > 768) {
- const navbarCollapse = document.querySelector('.navbar-collapse');
- if (navbarCollapse && navbarCollapse.classList.contains('show')) {
- // Use Bootstrap's collapse method if available
- if (typeof bootstrap !== 'undefined' && bootstrap.Collapse) {
- const collapse = bootstrap.Collapse.getInstance(navbarCollapse);
- if (collapse) {
- collapse.hide();
- }
- }
- }
+ return Array.from(document.querySelectorAll('[data-navigation-drawer]'));
+}
+
+function hideOffcanvasElement(offcanvasElement) {
+ if (!offcanvasElement || typeof bootstrap === 'undefined' || !bootstrap.Offcanvas) {
+ return;
+ }
+
+ const offcanvasInstance = bootstrap.Offcanvas.getInstance(offcanvasElement);
+ if (offcanvasInstance) {
+ offcanvasInstance.hide();
}
- });
}
-// Set up dropdown behaviors
-function setupDropdownBehaviors() {
- // Handle dropdown menu accessibility
- document.querySelectorAll('.dropdown-toggle').forEach(dropdown => {
- dropdown.addEventListener('keydown', function(e) {
- // Handle keyboard navigation for dropdowns
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
+function closeOpenDropdowns() {
+ document.querySelectorAll('.dropdown-menu.show').forEach((menu) => {
if (typeof bootstrap !== 'undefined' && bootstrap.Dropdown) {
- const dropdownInstance = bootstrap.Dropdown.getOrCreateInstance(this);
- dropdownInstance.toggle();
+ const dropdownToggle = menu.previousElementSibling;
+ if (dropdownToggle) {
+ const dropdownInstance = bootstrap.Dropdown.getInstance(dropdownToggle);
+ if (dropdownInstance) {
+ dropdownInstance.hide();
+ }
+ }
}
- }
});
- });
+}
- // Close dropdowns when clicking outside
- document.addEventListener('click', function(e) {
- if (!e.target.closest('.dropdown')) {
- document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
- if (typeof bootstrap !== 'undefined' && bootstrap.Dropdown) {
- const dropdownToggle = menu.previousElementSibling;
- if (dropdownToggle) {
- const dropdownInstance = bootstrap.Dropdown.getInstance(dropdownToggle);
- if (dropdownInstance) {
- dropdownInstance.hide();
+function handleResponsiveNavigation() {
+ window.addEventListener('resize', () => {
+ if (window.innerWidth > 991) {
+ const navbarCollapse = document.querySelector('.navbar-collapse');
+ if (navbarCollapse && navbarCollapse.classList.contains('show') && typeof bootstrap !== 'undefined' && bootstrap.Collapse) {
+ bootstrap.Collapse.getOrCreateInstance(navbarCollapse).hide();
}
- }
+
+ getNavigationOffcanvasElements().forEach((offcanvasElement) => {
+ hideOffcanvasElement(offcanvasElement);
+ });
+ }
+ });
+}
+
+function initializeMobileNavigationDrawers() {
+ getNavigationOffcanvasElements().forEach((offcanvasElement) => {
+ if (isChatRailNavigationDrawer(offcanvasElement)) {
+ return;
}
- });
+
+ const offcanvasInstance = bootstrap.Offcanvas.getOrCreateInstance(offcanvasElement);
+
+ offcanvasElement.querySelectorAll('a[href]').forEach((link) => {
+ link.addEventListener('click', () => {
+ if (window.innerWidth < 992) {
+ offcanvasInstance.hide();
+ }
+ });
+ });
+ });
+}
+
+function initializeNavigationOverlayCoordination() {
+ const topNavUserDropdown = document.getElementById('userDropdown');
+ const topNavDropdownContainer = topNavUserDropdown ? topNavUserDropdown.closest('.dropdown') : null;
+
+ if (topNavDropdownContainer && typeof bootstrap !== 'undefined' && bootstrap.Dropdown) {
+ topNavDropdownContainer.addEventListener('show.bs.dropdown', () => {
+ getNavigationOffcanvasElements().forEach((offcanvasElement) => {
+ hideOffcanvasElement(offcanvasElement);
+ });
+ });
}
- });
+
+ getNavigationOffcanvasElements().forEach((offcanvasElement) => {
+ offcanvasElement.addEventListener('show.bs.offcanvas', () => {
+ closeOpenDropdowns();
+ });
+ });
+}
+
+function setupDropdownBehaviors() {
+ document.querySelectorAll('.dropdown-toggle').forEach((dropdown) => {
+ dropdown.addEventListener('keydown', function(e) {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ if (typeof bootstrap !== 'undefined' && bootstrap.Dropdown) {
+ const dropdownInstance = bootstrap.Dropdown.getOrCreateInstance(this);
+ dropdownInstance.toggle();
+ }
+ }
+ });
+ });
+
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest('.dropdown')) {
+ closeOpenDropdowns();
+ }
+ });
}
-// Utility function to toggle navbar collapse on mobile
function toggleNavbarCollapse() {
- const navbarCollapse = document.querySelector('.navbar-collapse');
- if (navbarCollapse) {
- if (typeof bootstrap !== 'undefined' && bootstrap.Collapse) {
- const collapse = bootstrap.Collapse.getOrCreateInstance(navbarCollapse);
- collapse.toggle();
- } else {
- // Fallback for manual toggle
- navbarCollapse.classList.toggle('show');
+ const navbarCollapse = document.querySelector('.navbar-collapse');
+ if (navbarCollapse) {
+ if (typeof bootstrap !== 'undefined' && bootstrap.Collapse) {
+ const collapse = bootstrap.Collapse.getOrCreateInstance(navbarCollapse);
+ collapse.toggle();
+ } else {
+ navbarCollapse.classList.toggle('show');
+ }
}
- }
}
-// Export functions for use in other modules if needed
if (typeof module !== 'undefined' && module.exports) {
- module.exports = {
- handleResponsiveNavigation,
- setupDropdownBehaviors,
- toggleNavbarCollapse
- };
-}
+ module.exports = {
+ handleResponsiveNavigation,
+ setupDropdownBehaviors,
+ toggleNavbarCollapse
+ };
+}
\ No newline at end of file
diff --git a/application/single_app/static/js/sidebar.js b/application/single_app/static/js/sidebar.js
index 22b9daae..31b7b088 100644
--- a/application/single_app/static/js/sidebar.js
+++ b/application/single_app/static/js/sidebar.js
@@ -1,3 +1,5 @@
+// sidebar.js
+
// Sidebar Navigation Functionality
/**
@@ -57,6 +59,126 @@ function updateNavLayoutToggleText(navLayout) {
}
}
+function getSidebarElements() {
+ return {
+ body: document.body,
+ sidebar: document.getElementById('sidebar-nav'),
+ toggleButton: document.getElementById('sidebar-toggle-btn'),
+ floatingButton: document.getElementById('floating-expand-btn'),
+ mainContent: document.getElementById('main-content'),
+ allContentElements: document.querySelectorAll('.main-content, .container, .container-fluid, #main-content')
+ };
+}
+
+function syncSidebarControls(isExpanded, toggleButton, floatingButton) {
+ document.querySelectorAll('[data-sidebar-toggle="toggle"]').forEach((control) => {
+ control.setAttribute('aria-expanded', String(isExpanded));
+ });
+
+ if (floatingButton) {
+ floatingButton.classList.toggle('d-none', isExpanded);
+ floatingButton.classList.toggle('sidebar-floating-expand-visible', !isExpanded);
+ floatingButton.setAttribute('aria-hidden', String(isExpanded));
+ }
+}
+
+function setSidebarExpandedState(isExpanded) {
+ const {
+ body,
+ sidebar,
+ toggleButton,
+ floatingButton,
+ mainContent,
+ allContentElements
+ } = getSidebarElements();
+
+ if (!body || !sidebar) {
+ return false;
+ }
+
+ sidebar.classList.toggle('sidebar-expanded', isExpanded);
+ sidebar.classList.toggle('sidebar-collapsed', !isExpanded);
+ body.classList.toggle('sidebar-collapsed', !isExpanded);
+
+ allContentElements.forEach((element) => {
+ element.style.marginLeft = isExpanded ? '' : '0px';
+ element.style.maxWidth = isExpanded ? '' : '100%';
+ });
+
+ if (mainContent) {
+ mainContent.classList.toggle('sidebar-padding', isExpanded);
+ }
+
+ syncSidebarControls(isExpanded, toggleButton, floatingButton);
+ return isExpanded;
+}
+
+function toggleSidebar(event) {
+ if (event) {
+ event.preventDefault();
+ }
+
+ const { sidebar } = getSidebarElements();
+ if (!sidebar) {
+ return false;
+ }
+
+ const isExpanded = sidebar.classList.contains('sidebar-expanded') && !sidebar.classList.contains('sidebar-collapsed');
+ return setSidebarExpandedState(!isExpanded);
+}
+
+function initializeSidebarToggleButtons() {
+ document.querySelectorAll('[data-sidebar-toggle="toggle"]').forEach((button) => {
+ if (button.dataset.sidebarToggleBound === 'true') {
+ return;
+ }
+
+ button.dataset.sidebarToggleBound = 'true';
+ button.addEventListener('click', toggleSidebar);
+ });
+
+ const { body, sidebar } = getSidebarElements();
+ if (!body || !sidebar) {
+ return;
+ }
+
+ const isExpanded = !body.classList.contains('sidebar-collapsed') && !sidebar.classList.contains('sidebar-collapsed');
+ setSidebarExpandedState(isExpanded);
+}
+
+function initializeChatSidebarDrawer() {
+ const sidebar = document.querySelector('#sidebar-nav[data-navigation-drawer="chat-rail"]');
+ if (!sidebar || typeof bootstrap === 'undefined' || !bootstrap.Offcanvas) {
+ return;
+ }
+
+ if (sidebar.dataset.chatSidebarDrawerBound === 'true') {
+ return;
+ }
+
+ sidebar.dataset.chatSidebarDrawerBound = 'true';
+
+ sidebar.addEventListener('click', (event) => {
+ if (window.innerWidth > 991) {
+ return;
+ }
+
+ const dismissTrigger = event.target.closest('a[href], #sidebar-new-chat-btn, .sidebar-conversation-item');
+ if (!dismissTrigger || dismissTrigger.matches('a[href^="#"]')) {
+ return;
+ }
+
+ const offcanvasInstance = bootstrap.Offcanvas.getInstance(sidebar);
+ if (offcanvasInstance) {
+ offcanvasInstance.hide();
+ }
+ });
+}
+
+if (typeof window !== 'undefined') {
+ window.toggleSidebar = toggleSidebar;
+}
+
// Initialize sidebar navigation functionality
document.addEventListener('DOMContentLoaded', () => {
// On click, toggle nav layout in user settings and reload
@@ -102,6 +224,9 @@ document.addEventListener('DOMContentLoaded', () => {
// Default to top nav if error
updateNavLayoutToggleText('top');
});
+
+ initializeSidebarToggleButtons();
+ initializeChatSidebarDrawer();
});
// Export functions for use in other modules if needed
@@ -109,6 +234,8 @@ if (typeof module !== 'undefined' && module.exports) {
module.exports = {
getUserSettings,
setUserNavLayout,
- updateNavLayoutToggleText
+ updateNavLayoutToggleText,
+ toggleSidebar,
+ setSidebarExpandedState
};
}
diff --git a/application/single_app/static/js/workspace/group_agents.js b/application/single_app/static/js/workspace/group_agents.js
index 03a52f7c..d8040d93 100644
--- a/application/single_app/static/js/workspace/group_agents.js
+++ b/application/single_app/static/js/workspace/group_agents.js
@@ -489,7 +489,7 @@ function initialize() {
setupViewToggle('groupAgents', 'groupAgentsViewPreference', (mode) => {
currentViewMode = mode;
switchViewContainers(mode, agentsListView, agentsGridView);
- });
+ }, { mobileDefault: 'grid' });
if (document.getElementById("group-agents-tab-btn")?.classList.contains("active")) {
fetchGroupAgents();
diff --git a/application/single_app/static/js/workspace/group_plugins.js b/application/single_app/static/js/workspace/group_plugins.js
index 8acdf5bd..cb9a5a09 100644
--- a/application/single_app/static/js/workspace/group_plugins.js
+++ b/application/single_app/static/js/workspace/group_plugins.js
@@ -276,7 +276,7 @@ async function fetchGroupPlugins() {
document.getElementById('group-plugins-list-view'),
document.getElementById('group-plugins-grid-view')
);
- });
+ }, { mobileDefault: 'grid' });
} catch (error) {
console.error("Error loading group actions:", error);
renderError(error.message || "Unable to load group actions.");
diff --git a/application/single_app/static/js/workspace/view-utils.js b/application/single_app/static/js/workspace/view-utils.js
index 216a8ffe..3b0b7722 100644
--- a/application/single_app/static/js/workspace/view-utils.js
+++ b/application/single_app/static/js/workspace/view-utils.js
@@ -93,34 +93,69 @@ export function createViewToggleHtml(prefix) {
* @param {string} storageKey - localStorage key for persistence
* @param {function} onSwitch - Callback receiving 'list' or 'grid'
*/
-export function setupViewToggle(prefix, storageKey, onSwitch) {
+export function setupViewToggle(prefix, storageKey, onSwitch, options = {}) {
const listRadio = document.getElementById(`${prefix}-view-list`);
const gridRadio = document.getElementById(`${prefix}-view-grid`);
if (!listRadio || !gridRadio) return;
+ const mobileQuery = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
+ ? window.matchMedia(options.mobileQuery || '(max-width: 991.98px)')
+ : null;
+
+ function getPreferredMode() {
+ const saved = localStorage.getItem(storageKey);
+ if (options.mobileDefault && mobileQuery && mobileQuery.matches) {
+ return options.mobileDefault;
+ }
+
+ return saved === 'grid' ? 'grid' : 'list';
+ }
+
+ function applyMode(mode, persistPreference) {
+ const resolvedMode = mode === 'grid' ? 'grid' : 'list';
+ listRadio.checked = resolvedMode === 'list';
+ gridRadio.checked = resolvedMode === 'grid';
+
+ if (persistPreference) {
+ localStorage.setItem(storageKey, resolvedMode);
+ }
+
+ onSwitch(resolvedMode);
+ }
+
+ if (listRadio.dataset.viewToggleBound === 'true' && gridRadio.dataset.viewToggleBound === 'true') {
+ applyMode(getPreferredMode(), false);
+ return;
+ }
+
+ listRadio.dataset.viewToggleBound = 'true';
+ gridRadio.dataset.viewToggleBound = 'true';
+
listRadio.addEventListener("change", () => {
if (listRadio.checked) {
- localStorage.setItem(storageKey, "list");
- onSwitch("list");
+ applyMode('list', true);
}
});
gridRadio.addEventListener("change", () => {
if (gridRadio.checked) {
- localStorage.setItem(storageKey, "grid");
- onSwitch("grid");
+ applyMode('grid', true);
}
});
- // Restore saved preference
- const saved = localStorage.getItem(storageKey);
- if (saved === "grid") {
- gridRadio.checked = true;
- listRadio.checked = false;
- onSwitch("grid");
- } else {
- onSwitch("list");
+ const syncPreferredMode = () => {
+ applyMode(getPreferredMode(), false);
+ };
+
+ if (mobileQuery) {
+ if (typeof mobileQuery.addEventListener === 'function') {
+ mobileQuery.addEventListener('change', syncPreferredMode);
+ } else if (typeof mobileQuery.addListener === 'function') {
+ mobileQuery.addListener(syncPreferredMode);
+ }
}
+
+ syncPreferredMode();
}
/**
diff --git a/application/single_app/static/js/workspace/workspace-documents.js b/application/single_app/static/js/workspace/workspace-documents.js
index eefd0237..1b686b99 100644
--- a/application/single_app/static/js/workspace/workspace-documents.js
+++ b/application/single_app/static/js/workspace/workspace-documents.js
@@ -1,7 +1,7 @@
// static/js/workspace/workspace-documents.js
import { escapeHtml } from "./workspace-utils.js";
-import { initializeTags, renderTagBadges, loadWorkspaceTags } from "./workspace-tags.js";
+import { initializeTags, renderTagBadges, loadWorkspaceTags, currentView } from "./workspace-tags.js";
import { getSelectedTagsArray, setSelectedTags, clearSelectedTags, updateDocumentTagsDisplay, loadWorkspaceTags as loadTagManagementTags } from './workspace-tag-management.js';
// ------------- State Variables -------------
@@ -19,6 +19,7 @@ const activePolls = new Set();
// ------------- DOM Elements (Documents Tab) -------------
const documentsTableBody = document.querySelector("#documents-table tbody");
+const documentsCardView = document.getElementById("documents-card-view");
const docsPaginationContainer = document.getElementById("docs-pagination-container");
const docsPageSizeSelect = document.getElementById("docs-page-size-select");
const fileInput = document.getElementById("workspace-file-input");
@@ -59,6 +60,9 @@ window.docsCurrentPage = docsCurrentPage;
window.docsTagsFilter = docsTagsFilter;
window.selectedDocuments = selectedDocuments;
window.fetchUserDocuments = fetchUserDocuments;
+window.lastFetchedDocs = window.lastFetchedDocs || [];
+window.lastFetchedDocsError = null;
+window.hasFetchedUserDocuments = window.hasFetchedUserDocuments || false;
// ------------- Helper Functions -------------
function isColorLight(hexColor) {
@@ -90,6 +94,353 @@ function isColorLight(hexColor) {
return luminance > 0.5;
}
+function truncateDocumentText(text, maxLength = 150) {
+ if (!text) {
+ return "";
+ }
+
+ return text.length > maxLength ? `${text.slice(0, maxLength).trimEnd()}…` : text;
+}
+
+function getDocumentProcessingState(doc) {
+ const pctString = String(doc?.percentage_complete ?? "");
+ const pct = /^\d+(\.\d+)?$/.test(pctString) ? parseFloat(pctString) : 0;
+ const docStatus = doc?.status || "";
+ const normalizedStatus = docStatus.toLowerCase();
+ const hasError = normalizedStatus.includes("error");
+ const isComplete = pct >= 100 || normalizedStatus.includes("complete") || hasError;
+
+ return { pct, docStatus, hasError, isComplete };
+}
+
+function getPersonalDocumentAccess(doc) {
+ const currentUserId = window.current_user_id;
+ const isOwner = doc.user_id === currentUserId;
+ let sharedUserEntry = null;
+
+ if (!isOwner) {
+ sharedUserEntry = (doc.shared_user_ids || []).find(
+ entry => entry.startsWith(`${currentUserId},`)
+ ) || null;
+ }
+
+ return {
+ isOwner,
+ sharedUserEntry,
+ requiresApproval: Boolean(!isOwner && sharedUserEntry && sharedUserEntry.endsWith(",not_approved")),
+ hasApprovedAccess: isOwner || (!sharedUserEntry || sharedUserEntry.endsWith(",approved"))
+ };
+}
+
+function getDocumentCardIcon(fileName = "") {
+ const extension = (fileName.split('.').pop() || '').toLowerCase();
+ const iconMap = {
+ pdf: 'bi-filetype-pdf',
+ doc: 'bi-file-earmark-word',
+ docx: 'bi-file-earmark-word',
+ ppt: 'bi-file-earmark-slides',
+ pptx: 'bi-file-earmark-slides',
+ xls: 'bi-file-earmark-spreadsheet',
+ xlsx: 'bi-file-earmark-spreadsheet',
+ xlsm: 'bi-file-earmark-spreadsheet',
+ csv: 'bi-filetype-csv',
+ png: 'bi-file-earmark-image',
+ jpg: 'bi-file-earmark-image',
+ jpeg: 'bi-file-earmark-image',
+ gif: 'bi-file-earmark-image',
+ txt: 'bi-file-earmark-text',
+ md: 'bi-file-earmark-richtext',
+ html: 'bi-filetype-html',
+ json: 'bi-filetype-json',
+ xml: 'bi-filetype-xml'
+ };
+
+ return iconMap[extension] || 'bi-file-earmark-text';
+}
+
+function getDocumentClassificationBadge(doc) {
+ if (!(window.enable_document_classification === true || window.enable_document_classification === "true")) {
+ return '';
+ }
+
+ const currentLabel = doc.document_classification || null;
+ const categories = window.classification_categories || [];
+ const category = categories.find(cat => cat.label === currentLabel);
+
+ if (category) {
+ const bgColor = category.color || '#6c757d';
+ const textColorClass = isColorLight(bgColor) ? 'text-dark' : '';
+ return `${escapeHtml(category.label)}`;
+ }
+
+ if (currentLabel) {
+ return `${escapeHtml(currentLabel)}`;
+ }
+
+ return 'None';
+}
+
+function getDocumentMetaPills(doc) {
+ const pills = [];
+ const authors = Array.isArray(doc.authors)
+ ? doc.authors.filter(Boolean)
+ : (doc.authors ? [doc.authors] : []);
+
+ if (doc.version) {
+ pills.push(``);
+ }
+ if (doc.number_of_pages) {
+ pills.push(``);
+ }
+ if (authors.length) {
+ const authorLabel = authors.length > 2
+ ? `${authors.slice(0, 2).join(', ')} +${authors.length - 2}`
+ : authors.join(', ');
+ pills.push(``);
+ }
+ if (doc.publication_date) {
+ pills.push(``);
+ }
+
+ return pills.join('');
+}
+
+function getDocumentSummaryText(doc) {
+ const abstractText = truncateDocumentText((doc.abstract || '').trim(), 165);
+ if (abstractText) {
+ return abstractText;
+ }
+
+ const keywords = Array.isArray(doc.keywords)
+ ? doc.keywords.filter(Boolean).join(', ')
+ : (doc.keywords || '');
+
+ if (keywords) {
+ return `Keywords: ${truncateDocumentText(keywords, 165)}`;
+ }
+
+ return 'Use chat, metadata tools, and sharing actions directly from this document card.';
+}
+
+function renderDocumentsEmptyState(filtersActive) {
+ const message = filtersActive
+ ? 'No documents found matching the current filters.'
+ : 'No documents found. Upload a document to get started.';
+ const resetHtml = filtersActive
+ ? '${message}
+ ${filtersActive ? '' : ''} +${message}
+