From 89daadc7a0713da3e2b3d2fccf85a0cad936fac3 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 9 Oct 2026 08:38:23 -0400 Subject: [PATCH 1/2] Add native aggregate public chat Add persistent All and Visible public search scopes to V2 chat, replace the classic directory hand-off, enforce public-only retrieval across search and orchestration, and cover continuity and authorization boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../single_app/agent_execution_context.py | 11 +- application/single_app/config.py | 2 +- .../functions_orchestration_adapters.py | 42 +-- .../functions_orchestration_checkpoints.py | 2 +- .../functions_orchestration_context.py | 13 +- .../functions_orchestration_executor.py | 15 +- .../single_app/functions_public_workspaces.py | 26 ++ application/single_app/functions_search.py | 9 + .../single_app/functions_search_service.py | 20 +- application/single_app/public_chat_scope.py | 176 +++++++++++ application/single_app/route_backend_chats.py | 13 + .../single_app/route_backend_conversations.py | 1 + .../single_app/route_backend_orchestration.py | 8 + .../route_backend_public_documents.py | 43 ++- .../document_search_plugin.py | 11 +- .../v2_ui/src/components/chat/Composer.tsx | 46 ++- .../src/components/chat/ComposerEditor.tsx | 6 +- .../v2_ui/src/components/chat/ContextMenu.tsx | 7 +- .../components/chat/DocumentPickerPopover.tsx | 7 +- .../PublicDirectoryVisibilityTools.tsx | 4 +- application/v2_ui/src/lib/contextMentions.ts | 51 ++-- application/v2_ui/src/lib/documentScope.ts | 16 +- application/v2_ui/src/lib/endpoints.ts | 17 +- application/v2_ui/src/lib/publicChatScope.ts | 26 ++ application/v2_ui/src/lib/types.ts | 2 + application/v2_ui/src/pages/ChatPage.tsx | 28 +- .../v2_ui/src/pages/PublicDirectoryPage.tsx | 15 +- application/v2_ui/src/stores/chatStore.ts | 18 +- .../features/CHAT_CONTEXT_PICKER.md | 10 +- .../features/PUBLIC_DOCUMENT_READ_APIS.md | 14 + .../features/V2_AGGREGATE_PUBLIC_CHAT.md | 92 ++++++ .../features/V2_PUBLIC_DIRECTORY.md | 13 +- docs/guides/use-public-workspaces.md | 10 + docs/reference/chat-controls.md | 19 +- docs/reference/logging-tags.md | 2 + ...ocument_search_action_scope_enforcement.py | 24 +- .../test_v2_chat_context_picker.py | 4 +- functional_tests/test_v2_public_chat_scope.py | 287 ++++++++++++++++++ .../fixtures/orchestration/harness_entry.tsx | 4 +- ui_tests/test_v2_chat_context_selection.py | 15 +- ui_tests/test_v2_public_chat_scope.py | 190 ++++++++++++ ui_tests/test_v2_public_directory.py | 58 ++-- 42 files changed, 1247 insertions(+), 130 deletions(-) create mode 100644 application/single_app/public_chat_scope.py create mode 100644 application/v2_ui/src/lib/publicChatScope.ts create mode 100644 docs/explanation/features/V2_AGGREGATE_PUBLIC_CHAT.md create mode 100644 functional_tests/test_v2_public_chat_scope.py create mode 100644 ui_tests/test_v2_public_chat_scope.py diff --git a/application/single_app/agent_execution_context.py b/application/single_app/agent_execution_context.py index 907a61cb4..3e8f6831d 100644 --- a/application/single_app/agent_execution_context.py +++ b/application/single_app/agent_execution_context.py @@ -171,13 +171,14 @@ def agent_execution(frame): def capture_execution_identity(user_id, conversation_id=None): - """Capture only server-authenticated identity, never parent prompt or workspace state. + """Capture authenticated identity and admitted retrieval limits, never workspace preferences. Flask is imported here because workers use the context types without a Flask dependency. The closure creates a distinct app/request context for every invocation; concurrent children never share ``g`` or a mutable session. """ from flask import current_app, g, has_request_context, request, session + from public_chat_scope import current_public_chat_scope, public_chat_scope_context if not has_request_context(): return ExecutionIdentity(user_id, conversation_id) @@ -187,6 +188,7 @@ def capture_execution_identity(user_id, conversation_id=None): app = current_app._get_current_object() origin = request.host_url identity_session = {"user": deepcopy(authenticated_user)} + public_scope = deepcopy(current_public_chat_scope(user_id)) root_session = session._get_current_object() if session.get("token_cache"): identity_session["token_cache"] = session["token_cache"] @@ -223,7 +225,12 @@ def bridge(agent): } if group_id: g.conversation_group_id = group_id - yield + scope_context = ( + public_chat_scope_context(user_id, public_scope["selection"], public_scope["workspace_ids"]) + if public_scope else nullcontext() + ) + with scope_context: + yield return ExecutionIdentity( user_id, conversation_id, bridge, diff --git a/application/single_app/config.py b/application/single_app/config.py index 55eb38109..39190f940 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.309" +VERSION = "0.261.310" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_orchestration_adapters.py b/application/single_app/functions_orchestration_adapters.py index 739e04331..f0ce394e9 100644 --- a/application/single_app/functions_orchestration_adapters.py +++ b/application/single_app/functions_orchestration_adapters.py @@ -306,6 +306,8 @@ def _public_workspace_ids(context): def _document_scope(context, arguments): + if (_ctx(context, 'seeds', {}) or {}).get('public_workspace_selection'): + return 'public' standing_scope = _ctx(context, 'doc_scope', 'all') if _ctx(context, 'elicitation_references', None) and standing_scope in ('personal', 'group', 'public'): return standing_scope @@ -512,24 +514,28 @@ def resolve_context_source_manifest( if not ids: return [] - resolver = _ctx(context, 'resolve_source_manifest', None) - selection = selection_mode or _selection_mode(context) - if callable(resolver): - return list(resolver(ids) or []) - - from functions_analysis_access import resolve_analysis_source_manifest - - return list(resolve_analysis_source_manifest( - ids, - user_id or _ctx(context, 'user_id', None), - selection_mode=selection, - conversation_id=_ctx(context, 'conversation_id', None), - active_group_ids=_ctx(context, 'active_group_ids', None), - active_public_workspace_ids=_public_workspace_ids(context), - doc_scope=_ctx(context, 'doc_scope', 'all'), - cancel_requested=cancel_requested, - request_correlation_id=_ctx(context, 'request_correlation_id', None), - ) or []) + # Source readers run both before execution and in independent worker threads. + from public_chat_scope import public_chat_execution_scope + + with public_chat_execution_scope(context, user_id or _ctx(context, 'user_id', None), settings): + resolver = _ctx(context, 'resolve_source_manifest', None) + selection = selection_mode or _selection_mode(context) + if callable(resolver): + return list(resolver(ids) or []) + + from functions_analysis_access import resolve_analysis_source_manifest + + return list(resolve_analysis_source_manifest( + ids, + user_id or _ctx(context, 'user_id', None), + selection_mode=selection, + conversation_id=_ctx(context, 'conversation_id', None), + active_group_ids=_ctx(context, 'active_group_ids', None), + active_public_workspace_ids=_public_workspace_ids(context), + doc_scope=_document_scope(context, {}), + cancel_requested=cancel_requested, + request_correlation_id=_ctx(context, 'request_correlation_id', None), + ) or []) # -------------------------------------------------------------------------------------- diff --git a/application/single_app/functions_orchestration_checkpoints.py b/application/single_app/functions_orchestration_checkpoints.py index 11c5b6ce0..71e175491 100644 --- a/application/single_app/functions_orchestration_checkpoints.py +++ b/application/single_app/functions_orchestration_checkpoints.py @@ -51,7 +51,7 @@ 'conversation_context', 'context_message_ids', 'allowed_user_urls', 'chat_type', 'selection_mode', 'doc_scope', 'tags', 'document_filter_mode', 'active_group_ids', 'active_group_id', 'active_public_workspace_ids', - 'gpt_model', + 'gpt_model', 'public_workspace_selection', ) diff --git a/application/single_app/functions_orchestration_context.py b/application/single_app/functions_orchestration_context.py index e8cefb233..597630479 100644 --- a/application/single_app/functions_orchestration_context.py +++ b/application/single_app/functions_orchestration_context.py @@ -38,6 +38,10 @@ from datetime import datetime, timezone from functions_appinsights import log_event, workflow_log_context +from public_chat_scope import ( + PublicChatScopeError, aggregate_public_workspace_ids, current_public_chat_scope, + normalize_public_workspace_selection, +) from functions_action_catalog import build_action_planner_projection from functions_assist_references import sanitize_reference_label from functions_message_block_revisions import resolve_block_sources_in_content @@ -188,6 +192,7 @@ def resolve_seeds(request_data): return { **({'model_routing': 'auto'} if routing == 'auto' else {}), + 'public_workspace_selection': normalize_public_workspace_selection(request_data.get('public_workspace_selection')), 'document_ids': document_ids, 'document_labels': document_labels, 'doc_scope': _text(request_data.get('doc_scope')) or 'all', @@ -575,6 +580,9 @@ def _authorize_reference_scope(scope, user_id, settings, locked_contexts=None): the browser, or an active workspace, is never evidence of access. """ kind, scope_id = scope['kind'], scope['id'] + aggregate = current_public_chat_scope(user_id) + if aggregate and kind != 'public': + raise ElicitationContextError('Public search cannot use personal or group sources.', reason='workspace_unavailable') if not settings.get(WORKSPACE_SCOPE_SETTINGS[kind], False): raise ElicitationContextError( 'That workspace capability is currently disabled.', reason='workspace_disabled', @@ -608,7 +616,8 @@ def _authorize_reference_scope(scope, user_id, settings, locked_contexts=None): get_user_visible_public_workspace_ids_from_settings, ) - if scope_id not in get_user_visible_public_workspace_ids_from_settings(user_id): + public_ids = aggregate_public_workspace_ids(user_id) if aggregate else get_user_visible_public_workspace_ids_from_settings(user_id) + if scope_id not in public_ids: raise ElicitationContextError( 'That public workspace is not available.', reason='workspace_unavailable', ) @@ -1361,6 +1370,8 @@ def resolve_candidate_documents( tags_filter=seeds.get('tags') or None, document_filter_mode=seeds.get('document_filter_mode') or 'intersection', ) + except PublicChatScopeError: + raise except Exception as exc: log_event( f"[ORCHESTRATION_CONTEXT] Candidate document probe failed; planning without " diff --git a/application/single_app/functions_orchestration_executor.py b/application/single_app/functions_orchestration_executor.py index 2836c2c81..f5c9be964 100644 --- a/application/single_app/functions_orchestration_executor.py +++ b/application/single_app/functions_orchestration_executor.py @@ -48,6 +48,7 @@ from content_screening.access import assert_current_request_sources_available from content_screening.contracts import ScreeningError from functions_appinsights import log_event, workflow_log_context +from public_chat_scope import public_chat_execution_scope from functions_mixed_source_orchestration import ( AUTHORIZATION_STATUS_AUTHORIZED, MixedSourceCancellationError, @@ -431,6 +432,7 @@ def __init__( self.elicitation_references = list(elicitation_references or []) self.selected_document_ids = list(selected_document_ids or []) self.seeds = dict(seeds or {}) + self.public_workspace_selection = self.seeds.get('public_workspace_selection') self.original_seeds = dict(original_seeds or {}) self.user_message_id = user_message_id self.conversation_context = deepcopy(conversation_context or {}) @@ -774,11 +776,12 @@ def execute_plan( version = plan_contract_version(plan) if version != getattr(context, 'plan_contract_version', None): raise ResultContractError('result_version_unsupported') - return _execute_dependency_plan( - plan, context, settings=settings, user_id=user_id, emit=emit, - cancel_requested=cancel_requested, persist=persist, get_adapter=get_adapter, - checkpoints=checkpoints, - ) + with public_chat_execution_scope(context, user_id, settings): + return _execute_dependency_plan( + plan, context, settings=settings, user_id=user_id, emit=emit, + cancel_requested=cancel_requested, persist=persist, get_adapter=get_adapter, + checkpoints=checkpoints, + ) def _dependency_request_context(context): @@ -939,7 +942,7 @@ def _run_dependency_step( # to create. Every other Gather or Reason step stays unable to publish anything. capability = get_capability(step['capability_id'], contract_version=DEPENDENCY_PLAN_CONTRACT_VERSION) publishes = step['role'] == 'render' or (capability or {}).get('publishes_generated_images') is True - with orchestration_file_policy(allow_generated_files=publishes), model_scope: + with public_chat_execution_scope(scoped, user_id, settings), orchestration_file_policy(allow_generated_files=publishes), model_scope: if step['capability_id'] == 'render_file': result = _render_dependency_step( runtime_step, scoped, settings=settings, user_id=user_id, cancel_requested=cancel_probe, diff --git a/application/single_app/functions_public_workspaces.py b/application/single_app/functions_public_workspaces.py index 84cf62d32..30e3f13e4 100644 --- a/application/single_app/functions_public_workspaces.py +++ b/application/single_app/functions_public_workspaces.py @@ -5,6 +5,7 @@ import functions_settings from functions_group import * from typing import Iterable +from public_chat_scope import PublicChatScopeError, normalize_public_workspace_selection from functions_chat_bootstrap_cache import bump_chat_bootstrap_global_cache_version from functions_workspace_branding import ( @@ -513,6 +514,31 @@ def set_user_visible_public_workspaces(user_id: str, workspace_ids: list) -> Non update_user_settings(user_id, {"visiblePublicWorkspaceIds": workspace_ids}) +def resolve_public_chat_workspace_ids(user_id, selection, *, settings=None): + """Resolve aggregate curation against existing, chat-available public workspaces.""" + selection = normalize_public_workspace_selection(selection) + if not selection: + raise PublicChatScopeError("Choose All or Visible public workspaces.") + settings = functions_settings.get_settings() if settings is None else settings + if not settings.get("enable_public_workspaces", False): + raise PublicChatScopeError("Public workspaces are currently disabled.", "public_chat_disabled", 403) + workspaces = get_all_public_workspaces() + visible_ids = None + if selection == "visible": + user_settings = functions_settings.get_user_settings(user_id) + visible_ids = set(visible_public_workspace_ids_from_user_settings( + user_settings, list_public_workspaces=lambda: workspaces, + )) + resolved = [] + for workspace in workspaces: + workspace_id = workspace.get("id") + allowed, _reason = check_public_workspace_status_allows_operation(workspace, "chat") + if workspace_id and allowed and (visible_ids is None or workspace_id in visible_ids): + if workspace_id not in resolved: + resolved.append(workspace_id) + return resolved + + def add_visible_public_workspace(user_id: str, ws_id: str) -> None: """ Add a workspace to the user's visible list using publicDirectorySettings. diff --git a/application/single_app/functions_search.py b/application/single_app/functions_search.py index 768a38a83..806243527 100644 --- a/application/single_app/functions_search.py +++ b/application/single_app/functions_search.py @@ -2,6 +2,7 @@ import hashlib import logging +from public_chat_scope import aggregate_public_workspace_ids, current_public_chat_scope from typing import List, Dict, Any from content_screening.access import ( PROVENANCE_FIELD, @@ -121,6 +122,10 @@ def _resolve_public_workspace_ids_for_search( active_public_workspace_id=None, enforce_public_workspace_visibility=True, ): + aggregate_ids = aggregate_public_workspace_ids(user_id) + if aggregate_ids is not None: + requested_ids = normalize_search_id_list(active_public_workspace_id) + return [value for value in aggregate_ids if not requested_ids or value in requested_ids] requested_workspace_ids = normalize_search_id_list(active_public_workspace_id) if requested_workspace_ids and not enforce_public_workspace_visibility: return requested_workspace_ids @@ -414,6 +419,10 @@ def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, top_n = normalize_search_top_n(top_n) doc_scope = normalize_search_scope(doc_scope) + if current_public_chat_scope(user_id): + doc_scope = "public" + if not aggregate_public_workspace_ids(user_id): + return [] document_ids = normalize_search_id_list(document_ids) document_filter_mode = _normalize_document_filter_mode(document_filter_mode) diff --git a/application/single_app/functions_search_service.py b/application/single_app/functions_search_service.py index 2a6d2bdd5..ccefa7ff4 100644 --- a/application/single_app/functions_search_service.py +++ b/application/single_app/functions_search_service.py @@ -46,6 +46,7 @@ normalize_search_top_n, ) from functions_settings import get_settings, get_user_settings +from public_chat_scope import aggregate_public_workspace_ids, current_public_chat_scope SUMMARY_DEFAULT_WINDOW_UNIT = "pages" @@ -141,6 +142,10 @@ def _resolve_public_workspace_ids(user_id, active_public_workspace_id=None, visi it computed from a read-only settings snapshot, because the settings lookup can repair the user's settings document. The same validation and intersection apply either way. """ + aggregate_ids = aggregate_public_workspace_ids(user_id) + if aggregate_ids is not None: + requested_ids = normalize_search_id_list(active_public_workspace_id) + return [value for value in aggregate_ids if not requested_ids or value in requested_ids] try: if visible_public_workspace_ids is None: visible_ids = get_user_visible_public_workspace_ids_from_settings(user_id) @@ -555,6 +560,8 @@ def resolve_document_context( visible_public_workspace_ids=None, ): normalized_scope = normalize_search_scope(doc_scope) + if current_public_chat_scope(user_id): + normalized_scope = "public" public_visibility = ( {} if visible_public_workspace_ids is None else {"visible_public_workspace_ids": visible_public_workspace_ids} @@ -591,6 +598,8 @@ def resolve_document_context( if public_context: return public_context + if current_public_chat_scope(user_id): + return None chat_upload_context = _resolve_chat_upload_context( document_id=document_id, user_id=user_id, @@ -616,6 +625,9 @@ def resolve_document_contexts( ): """Resolve documents, optionally requiring explicit scopes and an accepted access path.""" normalized_scope = normalize_search_scope(doc_scope) + aggregate = current_public_chat_scope(user_id) + if aggregate: + normalized_scope = "public" normalized_document_ids = normalize_search_id_list(document_ids) authorized_group_ids = [] if normalized_scope in ("all", "group") and ( @@ -637,7 +649,7 @@ def resolve_document_contexts( normalized_conversation_id = str(conversation_id or "").strip() chat_conversation_authorized = bool( - normalized_conversation_id + not aggregate and normalized_conversation_id and _authorize_chat_upload_conversation(user_id, normalized_conversation_id) ) @@ -696,6 +708,8 @@ def build_search_request( raise ValueError("Query is required") normalized_scope = normalize_search_scope(doc_scope) + if current_public_chat_scope(user_id): + normalized_scope = "public" normalized_top_n = normalize_search_top_n(top_n, SEARCH_DEFAULT_TOP_N, SEARCH_MAX_TOP_N) normalized_document_ids = normalize_search_id_list(document_ids) if document_id and not normalized_document_ids: @@ -731,7 +745,7 @@ def build_search_request( if resolved_public_workspace_ids and normalized_scope in ("all", "public"): search_request["active_public_workspace_id"] = ( resolved_public_workspace_ids - if include_all_public_workspaces + if include_all_public_workspaces or current_public_chat_scope(user_id) else resolved_public_workspace_ids[0] ) @@ -751,6 +765,8 @@ def search_documents( enable_file_sharing=True, include_all_public_workspaces=False, ): + if current_public_chat_scope(user_id) and not aggregate_public_workspace_ids(user_id): + return {"query": query, "scope": "public", "results": [], "result_count": 0, "document_count": 0} search_request = build_search_request( query=query, user_id=user_id, diff --git a/application/single_app/public_chat_scope.py b/application/single_app/public_chat_scope.py new file mode 100644 index 000000000..ecdd8149d --- /dev/null +++ b/application/single_app/public_chat_scope.py @@ -0,0 +1,176 @@ +# public_chat_scope.py +"""Aggregate public search intent, separate from workspace and conversation ownership.""" + +from contextlib import contextmanager +from contextvars import ContextVar +from functools import wraps +import logging + +from flask import g, has_request_context, jsonify, request + + +_PUBLIC_CHAT_SCOPE = ContextVar("public_chat_scope", default=None) + + +class PublicChatScopeError(ValueError): + """A safe refusal that must not be downgraded to an ordinary document search.""" + + def __init__(self, message, code="public_chat_scope_invalid", status_code=400): + super().__init__(message) + self.public_message = message + self.code = code + self.status_code = status_code + + +def normalize_public_workspace_selection(value): + if value is None: + return None + if not isinstance(value, str) or value not in ("all", "visible"): + raise PublicChatScopeError("Choose All or Visible public workspaces.") + return value + + +def current_public_chat_scope(user_id): + scope = _PUBLIC_CHAT_SCOPE.get() + if scope is None and has_request_context(): + scope = getattr(g, "public_chat_scope", None) + return scope if isinstance(scope, dict) and scope.get("user_id") == user_id else None + + +@contextmanager +def public_chat_scope_context(user_id, selection, workspace_ids): + selection = normalize_public_workspace_selection(selection) + scope = {"user_id": user_id, "selection": selection, "workspace_ids": list(workspace_ids)} + token = _PUBLIC_CHAT_SCOPE.set(scope if selection else None) + try: + yield + finally: + _PUBLIC_CHAT_SCOPE.reset(token) + + +def aggregate_public_workspace_ids(user_id): + """Recheck availability without allowing a downstream tool to widen an admitted turn.""" + scope = current_public_chat_scope(user_id) + if not scope: + return None + # Deferred until execution: this module must stay below the config/storage bootstrap. + from functions_public_workspaces import resolve_public_chat_workspace_ids + + eligible = resolve_public_chat_workspace_ids(user_id, scope["selection"]) + allowed = set(scope["workspace_ids"]) + workspace_ids = [workspace_id for workspace_id in eligible if workspace_id in allowed] + if not workspace_ids: + raise PublicChatScopeError( + "No public workspaces remain available for this search.", "public_chat_scope_empty", 409, + ) + return workspace_ids + + +def _intersect_public_scope_lock(workspace_ids, conversation): + if not isinstance(conversation, dict) or not conversation.get("scope_locked"): + return workspace_ids + contexts = conversation.get("locked_contexts") or conversation.get("context") or [] + locked_ids = { + item.get("id") for item in contexts + if isinstance(item, dict) and item.get("scope") == "public" + } + return [workspace_id for workspace_id in workspace_ids if workspace_id in locked_ids] + + +@contextmanager +def public_chat_execution_scope(context, user_id, settings): + """Bind persisted, admitted scope in workers that do not inherit Flask request state.""" + seeds = (context.get("seeds") if isinstance(context, dict) else getattr(context, "seeds", None)) or {} + selection = normalize_public_workspace_selection(seeds.get("public_workspace_selection")) + if not selection: + yield + return + from functions_public_workspaces import resolve_public_chat_workspace_ids + + admitted_ids = set(seeds.get("active_public_workspace_ids") or []) + eligible_ids = resolve_public_chat_workspace_ids(user_id, selection, settings=settings) + workspace_ids = [workspace_id for workspace_id in eligible_ids if workspace_id in admitted_ids] + conversation_id = context.get("conversation_id") if isinstance(context, dict) else getattr(context, "conversation_id", None) + if conversation_id: + # Execution/resume must use current ownership and locks, not a planned snapshot. + from config import cosmos_conversations_container + from azure.cosmos.exceptions import CosmosResourceNotFoundError + + try: + conversation = cosmos_conversations_container.read_item(item=conversation_id, partition_key=conversation_id) + except CosmosResourceNotFoundError as error: + raise PublicChatScopeError("That conversation is no longer available.", "public_chat_conversation_missing", 404) from error + if conversation.get("user_id") != user_id: + raise PermissionError("That conversation is not available.") + workspace_ids = _intersect_public_scope_lock(workspace_ids, conversation) + if not workspace_ids: + raise PublicChatScopeError("No public workspaces remain available for this run.", "public_chat_scope_empty", 409) + with public_chat_scope_context(user_id, selection, workspace_ids): + yield + + +def prepare_public_chat_scope(data, user_id, settings, conversation=None): + selection = normalize_public_workspace_selection(data.get("public_workspace_selection")) + if not selection: + return None + if data.get("analysis_result_context") is not None or data.get("workflow_result_context") is not None: + raise PublicChatScopeError("Return to Current context to ask about a saved result.") + if data.get("image_generation"): + raise PublicChatScopeError("Return to Current context to generate an image.") + # Storage is an execution dependency, not part of this module's import lifecycle. + from functions_public_workspaces import resolve_public_chat_workspace_ids + + workspace_ids = resolve_public_chat_workspace_ids(user_id, selection, settings=settings) + workspace_ids = _intersect_public_scope_lock(workspace_ids, conversation) + if not workspace_ids: + raise PublicChatScopeError( + "No public workspaces are available for this scope. Check directory visibility or conversation scope restrictions.", + "public_chat_scope_empty", + 409, + ) + scope = {"user_id": user_id, "selection": selection, "workspace_ids": workspace_ids} + g.public_chat_scope = scope + data.update({ + "doc_scope": "public", + "hybrid_search": True, + "active_group_id": None, + "active_group_ids": [], + "active_public_workspace_id": workspace_ids[0], + "active_public_workspace_ids": workspace_ids, + }) + return scope + + +def public_chat_scope_required(authorize_conversation): + """Admit explicit aggregate intent before a route starts model work or streaming.""" + def decorate(function): + @wraps(function) + def wrapped(*args, **kwargs): + data = request.get_json(silent=True) + if not isinstance(data, dict) or data.get("public_workspace_selection") is None: + return function(*args, **kwargs) + # These owners require initialized storage/logging; load them only at admission. + from functions_appinsights import log_event + from functions_authentication import get_current_user_id + from functions_settings import get_settings + + user_id = get_current_user_id() + if not user_id: + return jsonify({"error": "User not authenticated"}), 401 + try: + conversation_id = data.get("conversation_id") + conversation = authorize_conversation(user_id, conversation_id) if conversation_id else None + prepare_public_chat_scope(data, user_id, get_settings(), conversation) + except PublicChatScopeError as error: + return jsonify({"error": error.public_message, "error_code": error.code}), error.status_code + except LookupError: + return jsonify({"error": "Conversation not found"}), 404 + except PermissionError: + return jsonify({"error": "Forbidden"}), 403 + except Exception as error: + log_event("[PUBLIC_CHAT_SCOPE] Could not resolve public chat scope.", + extra={"error_type": type(error).__name__}, level=logging.ERROR) + return jsonify({"error": "Could not load the public chat scope. Please retry."}), 503 + return function(*args, **kwargs) + return wrapped + return decorate diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 77adc4a71..536dcfcb2 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -128,6 +128,7 @@ import ast import csv from functools import wraps +from public_chat_scope import aggregate_public_workspace_ids, public_chat_scope_required import io import inspect import json @@ -3625,6 +3626,14 @@ def _get_authorized_chat_scope_context( active_public_workspace_ids=None, ): """Filter request-provided chat scopes down to the caller's current access.""" + aggregate_ids = aggregate_public_workspace_ids(user_id) + if aggregate_ids is not None: + return { + 'active_group_ids': [], + 'active_group_id': None, + 'active_public_workspace_ids': aggregate_ids, + 'active_public_workspace_id': aggregate_ids[0] if aggregate_ids else None, + } requested_group_ids = _normalize_requested_scope_ids(active_group_ids, active_group_id) allowed_group_ids = [] for group_id in requested_group_ids: @@ -17504,6 +17513,7 @@ def image_proposal_status(conversation_id): @swagger_route(security=get_auth_security()) @login_required @user_required + @public_chat_scope_required(_authorize_personal_conversation_access) @_with_m365_pending_action_cards def chat_api(): publish_background_event = getattr( @@ -18583,6 +18593,7 @@ def result_requires_message_reload(result: Any) -> bool: 'document_context_requested': document_context_requested, 'hybrid_search_preference': bool(hybrid_search_enabled), 'document_scope': effective_document_scope, + 'public_workspace_selection': data.get('public_workspace_selection'), 'selected_document_id': effective_selected_document_id, 'selected_document_ids': effective_selected_document_ids, 'requested_document_ids': requested_selected_document_ids, @@ -21867,6 +21878,7 @@ def gpt_error(e): @swagger_route(security=get_auth_security()) @login_required @user_required + @public_chat_scope_required(_authorize_personal_conversation_access) def chat_stream_api(): """ Streaming version of chat endpoint using Server-Sent Events (SSE). @@ -23181,6 +23193,7 @@ def collect_stream_response_conversation_metadata(): 'document_context_requested': document_context_requested, 'hybrid_search_preference': bool(hybrid_search_enabled), 'document_scope': effective_document_scope, + 'public_workspace_selection': data.get('public_workspace_selection'), 'selected_document_id': effective_selected_document_id, 'selected_document_ids': effective_selected_document_ids, 'requested_document_ids': requested_selected_document_ids, diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index a4ccb9beb..6c7714c73 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -343,6 +343,7 @@ def _build_replayed_document_context(original_metadata): 'selected_document_id': selected_document_ids[0] if selected_document_ids else None, 'selected_document_ids': selected_document_ids, 'doc_scope': workspace_search.get('document_scope') or workspace_search.get('scope'), + 'public_workspace_selection': workspace_search.get('public_workspace_selection'), 'top_n': workspace_search.get('top_n'), 'classifications': ( workspace_search.get('classification') diff --git a/application/single_app/route_backend_orchestration.py b/application/single_app/route_backend_orchestration.py index 4b575e3bb..86238b8df 100644 --- a/application/single_app/route_backend_orchestration.py +++ b/application/single_app/route_backend_orchestration.py @@ -51,6 +51,7 @@ from content_screening.contracts import DocumentHeldError, ScreeningError from functions_activity_logging import log_workflow_creation from functions_appinsights import log_event, workflow_log_context +from public_chat_scope import current_public_chat_scope, prepare_public_chat_scope, public_chat_scope_required from functions_chat_content_checks import ( CHECK_METADATA, check_chat_content, orchestration_input_text, should_withhold_chat_event, strip_private_chat_checks, @@ -1121,6 +1122,9 @@ def _save_turn_message( metadata = { 'orchestration': {'turn_id': turn_id}, 'orchestration_turn_id': turn_id, + 'workspace_search': { + 'public_workspace_selection': (current_public_chat_scope(user_id) or {}).get('selection'), + }, } if prompt_selection: metadata['prompt_selection'] = prompt_selection @@ -1746,6 +1750,7 @@ def orchestration_retry_output(run_id, output_id): @swagger_route(security=get_auth_security()) @login_required @user_required + @public_chat_scope_required(lambda user_id, conversation_id: _authorize_context_conversation(conversation_id, user_id)) def orchestration_plan(): """Plan one request, streaming progress and ending with a plan or a question.""" settings = get_settings() @@ -2161,6 +2166,8 @@ def generate(): turn_context['resolved_message'] = effective_message effective_request = build_elicitation_user_request(effective_message, answered_record) conversation_document = _authorize_context_conversation(resolved_conversation_id, user_id) + if seeds.get('public_workspace_selection'): + prepare_public_chat_scope(seeds, user_id, settings, conversation_document) memory_context = load_orchestration_memory( user_id, conversation_document, effective_request, settings=settings, seeds=seeds, @@ -2173,6 +2180,7 @@ def generate(): if ( resolution.get('requires_retrieval') is False and not seeds.get('document_ids') and not seeds.get('elicitation_references') + and not seeds.get('public_workspace_selection') ): candidates = [] else: diff --git a/application/single_app/route_backend_public_documents.py b/application/single_app/route_backend_public_documents.py index 6dab14164..ef85f3a34 100644 --- a/application/single_app/route_backend_public_documents.py +++ b/application/single_app/route_backend_public_documents.py @@ -34,6 +34,7 @@ from flask import current_app from functions_debug import * from swagger_wrapper import swagger_route, get_auth_security +from public_chat_scope import PublicChatScopeError PENDING_GENERATED_ARTIFACT_NOTIFICATION_TYPES = [ @@ -521,14 +522,24 @@ def api_list_public_workspace_documents(): if not user_id: return jsonify({'error': 'User not authenticated'}), 401 - # Get user settings to access publicDirectorySettings - settings = get_user_settings(user_id) - public_directory_settings = settings.get('settings', {}).get('publicDirectorySettings', {}) - - # Get IDs of workspaces marked as visible (value is true) - workspace_ids = [ws_id for ws_id, is_visible in public_directory_settings.items() if is_visible] + selection = request.args.get('public_workspace_selection') + if selection is not None: + try: + workspace_ids = resolve_public_chat_workspace_ids(user_id, selection) + except PublicChatScopeError as error: + return jsonify({'error': error.public_message, 'error_code': error.code}), error.status_code + except Exception as error: + log_event('[PUBLIC_CHAT_SCOPE] Could not load public document scope.', + extra={'error_type': type(error).__name__}, level=logging.ERROR) + return jsonify({'error': 'Could not load public documents. Please retry.'}), 503 + else: + settings = get_user_settings(user_id) + public_directory_settings = settings.get('settings', {}).get('publicDirectorySettings', {}) + workspace_ids = [ws_id for ws_id, is_visible in public_directory_settings.items() if is_visible] if not workspace_ids: + if selection is not None: + return jsonify({'documents': [], 'workspace_name': 'Public workspaces'}), 200 return jsonify({ 'documents': [], 'workspace_name': 'All Public Workspaces', @@ -538,7 +549,7 @@ def api_list_public_workspace_documents(): # Get page_size parameter for pagination try: page_size = int(request.args.get('page_size', 1000)) - except: + except (TypeError, ValueError): page_size = 1000 if page_size < 1: page_size = 1000 @@ -554,7 +565,7 @@ def api_list_public_workspace_documents(): extra={'workspace_count': len(workspace_ids), 'error': str(e)}, level=logging.ERROR, ) - return jsonify({'error': f'Error fetching documents: {str(e)}'}), 500 + return jsonify({'error': 'Could not load public documents. Please retry.'}), 500 docs = docs[:page_size] @@ -1341,13 +1352,25 @@ def api_get_public_workspace_document_tags(): return jsonify({'error': 'User not authenticated'}), 401 ws_ids_param = request.args.get('workspace_ids', '') + selection = request.args.get('public_workspace_selection') + if selection is not None: + try: + eligible_ids = resolve_public_chat_workspace_ids(user_id, selection) + except PublicChatScopeError as error: + return jsonify({'error': error.public_message, 'error_code': error.code}), error.status_code + except Exception as error: + log_event('[PUBLIC_CHAT_SCOPE] Could not load public tag scope.', + extra={'error_type': type(error).__name__}, level=logging.ERROR) + return jsonify({'error': 'Could not load public tags. Please retry.'}), 503 + else: + eligible_ids = get_user_visible_public_workspace_ids_from_settings(user_id) if ws_ids_param: workspace_ids = [wid.strip() for wid in ws_ids_param.split(',') if wid.strip()] else: - workspace_ids = get_user_visible_public_workspace_ids_from_settings(user_id) + workspace_ids = eligible_ids - visible_ids = set(get_user_visible_public_workspace_ids_from_settings(user_id)) + visible_ids = set(eligible_ids) validated_ids = list(dict.fromkeys(wid for wid in workspace_ids if wid in visible_ids)) from functions_documents import build_workspace_tags_from_counts, normalize_tag diff --git a/application/single_app/semantic_kernel_plugins/document_search_plugin.py b/application/single_app/semantic_kernel_plugins/document_search_plugin.py index f37012d80..180ea04ee 100644 --- a/application/single_app/semantic_kernel_plugins/document_search_plugin.py +++ b/application/single_app/semantic_kernel_plugins/document_search_plugin.py @@ -6,6 +6,7 @@ from content_screening.contracts import ScreeningError from functions_authentication import get_current_user_id +from public_chat_scope import current_public_chat_scope from functions_agent_document_citations import annotate_document_search_payload from functions_search import ( SEARCH_DEFAULT_TOP_N, @@ -183,7 +184,7 @@ def _resolve_doc_scope(self, requested_scope: str) -> str: return normalize_search_scope(self._get_additional_fields().get('default_doc_scope', 'all')) def _resolve_doc_scope_requests(self, requested_scope: str) -> List[str]: - scope = self._resolve_doc_scope(requested_scope) + scope = 'public' if current_public_chat_scope(self._get_user_id()) else self._resolve_doc_scope(requested_scope) allowed_scopes = normalize_allowed_search_scopes(self._get_additional_fields()) scope_requests = resolve_allowed_scope_requests(scope, allowed_scopes) if not scope_requests: @@ -249,6 +250,14 @@ def _resolve_allowed_group_ids(self, active_group_ids: Any) -> Union[List[str], ) def _resolve_allowed_public_workspace_ids(self, active_public_workspace_id: Any) -> Union[List[str], str]: + aggregate = current_public_chat_scope(self._get_user_id()) + if aggregate: + requested = normalize_search_id_list(active_public_workspace_id) or aggregate['workspace_ids'] + resolved = intersect_allowed_search_ids(requested, self._get_additional_fields().get('allowed_public_workspace_ids')) + resolved = [value for value in normalize_search_id_list(resolved) if value in aggregate['workspace_ids']] + if not resolved: + raise DocumentSearchScopeError("Document search workspace is not allowed for this action.") + return resolved return intersect_allowed_search_ids( active_public_workspace_id, self._get_additional_fields().get('allowed_public_workspace_ids'), diff --git a/application/v2_ui/src/components/chat/Composer.tsx b/application/v2_ui/src/components/chat/Composer.tsx index 67e586470..41e6c6d42 100644 --- a/application/v2_ui/src/components/chat/Composer.tsx +++ b/application/v2_ui/src/components/chat/Composer.tsx @@ -35,6 +35,8 @@ import { buildSelectionFields, hasResolvableAgent } from '../../lib/chatRequestS import { modelSelectionKey, findModel, type ModelCatalogEntry } from '../../lib/models'; import { promptUrls, resolveGating } from '../../lib/composerGating'; import { resolveDocumentScope } from '../../lib/documentScope'; +import { readPublicWorkspaceSelection } from '../../lib/publicChatScope'; +import { usePublicWorkspaceLabels } from '../../lib/publicWorkspaceLabels'; import { addContextItem, contextDocumentDescriptors, @@ -169,6 +171,9 @@ function ToolToggle({ } export function Composer({ initialAgentSelection }: { initialAgentSelection?: string } = {}) { + const publicWorkspaceSelection = useChatStore((state) => state.publicWorkspaceSelection); + const setPublicWorkspaceSelection = useChatStore((state) => state.setPublicWorkspaceSelection); + const publicLabels = usePublicWorkspaceLabels(); const { streaming, sendMessage, stopStreaming, activeConversationId, analysisResultContext, clearAnalysisResultContext, @@ -261,6 +266,9 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st const hasMentionChips = (draft.mentions?.length ?? 0) > 0; const promptInstance = draft.promptInstance ?? 0; const contextItems = composerDraftContextItems(draft); + const nonPublicReferences = contextItems.some((item) => item.scope.kind !== 'public'); + const publicScopeConflict = Boolean(publicWorkspaceSelection) + && (nonPublicReferences || contextItems.some((item) => item.kind === 'scope')); const imageCapability = useImageEditCapability(); const referenceLimit = effectiveReferenceImageLimit(imageCapability); const referenceImagesAvailable = referenceLimit > 0 && !shared; @@ -1005,6 +1013,10 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st ]); const submit = (allowUnfilled = false) => { + if (publicWorkspaceSelection && (!features.enable_public_workspaces || publicScopeConflict || answeringFromStoredResult || (options.imageGeneration && !orchestrating))) { + toast.error('Public search needs public sources only. Clear incompatible sources or choose Current context.'); + return; + } if (streaming || !canPost || uploadsBlocked || workflowResultOpening) { return; } @@ -1111,6 +1123,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st // references this message was written with. void sendMessage(outgoing.message, { ...options, + publicWorkspaceSelection, contextItems, imageReferences: options.imageGeneration && !orchestrating ? draftImageReferences : [], promptInfo: outgoing.promptInfo, @@ -1132,6 +1145,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st const buildOrchestrationSeeds = (message: string, promptInfo: Json | null = null): Record => { const workspaces = contextScopes(contextItems); const scope = resolveDocumentScope({ + publicWorkspaceSelection, activeGroupId: bootstrap?.scope?.active_group_id, activePublicWorkspaceId: bootstrap?.scope?.active_public_workspace_id, contextGroupIds: workspaces.groupIds, @@ -1143,7 +1157,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st ...(imageProposalsRequested ? { image_generation_enabled: true } : {}), required_capabilities: [ // Pinned sources constrain inputs, not the operation (search, Analyze, or Compare). - ...(options.documentSearch ? ['document_search'] : []), + ...(options.documentSearch || publicWorkspaceSelection ? ['document_search'] : []), ...(options.webSearch ? ['web_search'] : []), ...(options.deepResearch ? ['deep_research'] : []), ...(options.urlAccess && promptUrls(message).length > 0 ? ['url_fetch'] : []), @@ -1637,7 +1651,35 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st )} + {!shared && (features.enable_public_workspaces || publicWorkspaceSelection) && ( +
+ + + {publicWorkspaceSelection && + {publicWorkspaceSelection === 'all' ? 'Public only, including hidden workspaces.' : 'Public only, using your directory visibility choices.'} + } + {publicScopeConflict &&

+ {nonPublicReferences + ? 'Clear personal or group references, or choose Current context before sending.' + : 'Clear whole-workspace references, or choose Current context to search a selected workspace.'} +

} + {publicWorkspaceSelection && !features.enable_public_workspaces &&

+ Public workspaces are currently disabled. Choose Current context to continue. +

} +
+ )} { + onToggleSearchAll={publicWorkspaceSelection ? undefined : () => { clearAnalysisResultContext(); setOptions((current) => ({ ...current, documentSearch: !current.documentSearch })); }} diff --git a/application/v2_ui/src/components/chat/ComposerEditor.tsx b/application/v2_ui/src/components/chat/ComposerEditor.tsx index b2361d0f2..4e0169689 100644 --- a/application/v2_ui/src/components/chat/ComposerEditor.tsx +++ b/application/v2_ui/src/components/chat/ComposerEditor.tsx @@ -82,11 +82,13 @@ import { import { ContextChips } from './ContextChips'; import { ContextMenu, useContextSuggestions, type ContextSearchScope } from './ContextMenu'; import { DocumentPickerPopover } from './DocumentPickerPopover'; +import type { PublicWorkspaceSelection } from '../../lib/publicChatScope'; import { MentionMenu, useMentionSuggestions } from './MentionMenu'; import { ComposerMentionChips } from './MentionPills'; import { PromptSlashMenu } from './PromptSlashMenu'; export interface ComposerEditorProps { + publicWorkspaceSelection?: PublicWorkspaceSelection | null; id: string; label: string; draft: ComposerDraft; @@ -162,6 +164,7 @@ function ComposerUploadThumbnail({ upload }: { upload: ComposerUpload }) { } export function ComposerEditor({ + publicWorkspaceSelection, id, label, draft, @@ -232,6 +235,7 @@ export function ComposerEditor({ const pickerOpen = controlledPickerOpen ?? localPickerOpen; const setPickerOpen = onPickerOpenChange ?? setLocalPickerOpen; const scope: ContextSearchScope = useMemo(() => ({ + publicWorkspaceSelection: referenceUploadsOnly ? null : publicWorkspaceSelection, groups: (bootstrap?.scope?.groups ?? []) as WorkspaceRef[], publicWorkspaces: (bootstrap?.scope?.public_workspaces ?? []) as WorkspaceRef[], groupsEnabled: Boolean(features.enable_group_workspaces), @@ -240,7 +244,7 @@ export function ComposerEditor({ // workspaces are not offered there. workspacesEnabled: !restricted, documentsOnly: restricted && contextDocumentsOnly, - }), [bootstrap?.scope, contextDocumentsOnly, features.enable_group_workspaces, features.enable_public_workspaces, restricted]); + }), [bootstrap?.scope, contextDocumentsOnly, features.enable_group_workspaces, features.enable_public_workspaces, restricted, publicWorkspaceSelection, referenceUploadsOnly]); const { candidates, loading } = useContextSuggestions( disabled || !contextEnabled ? null : contextQuery?.query ?? null, scope, ); diff --git a/application/v2_ui/src/components/chat/ContextMenu.tsx b/application/v2_ui/src/components/chat/ContextMenu.tsx index 1daaf87be..bd91db6ce 100644 --- a/application/v2_ui/src/components/chat/ContextMenu.tsx +++ b/application/v2_ui/src/components/chat/ContextMenu.tsx @@ -19,6 +19,8 @@ import { } from '../../lib/contextMentions'; import type { ContextKind } from '../../lib/chatContext'; import type { WorkspaceRef } from '../../lib/types'; +import type { PublicWorkspaceSelection } from '../../lib/publicChatScope'; +import { toast } from '../../stores/toastStore'; /** How long to wait after a keystroke before asking the server for candidates. */ const SEARCH_DEBOUNCE_MS = 250; @@ -36,6 +38,7 @@ const GROUP_LABEL: Record = { }; export interface ContextSearchScope { + publicWorkspaceSelection?: PublicWorkspaceSelection | null; groups: WorkspaceRef[]; publicWorkspaces: WorkspaceRef[]; groupsEnabled: boolean; @@ -83,6 +86,7 @@ export function useContextSuggestions( const timer = window.setTimeout(() => { searchContextCandidates({ + publicWorkspaceSelection: scopeRef.current.publicWorkspaceSelection, query, groups: scopeRef.current.groups, publicWorkspaces: scopeRef.current.publicWorkspaces, @@ -100,6 +104,7 @@ export function useContextSuggestions( }) .catch(() => { if (!controller.signal.aborted) { + if (scopeRef.current.publicWorkspaceSelection) toast.error('Could not load public search sources. Please retry.'); // Every individual request is already settled independently, so // reaching here means the fan-out itself failed. Offering nothing is // better than an error banner over the message box. @@ -113,7 +118,7 @@ export function useContextSuggestions( window.clearTimeout(timer); controller.abort(); }; - }, [query, groupIds, publicIds, scope.documentsOnly, scope.groupsEnabled, scope.publicEnabled, scope.workspacesEnabled]); + }, [query, groupIds, publicIds, scope.documentsOnly, scope.groupsEnabled, scope.publicEnabled, scope.workspacesEnabled, scope.publicWorkspaceSelection]); return { candidates, loading }; } diff --git a/application/v2_ui/src/components/chat/DocumentPickerPopover.tsx b/application/v2_ui/src/components/chat/DocumentPickerPopover.tsx index d9363b993..c2e8ce873 100644 --- a/application/v2_ui/src/components/chat/DocumentPickerPopover.tsx +++ b/application/v2_ui/src/components/chat/DocumentPickerPopover.tsx @@ -57,6 +57,7 @@ export function DocumentPickerPopover({ const [query, setQuery] = useState(''); const [candidates, setCandidates] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const holder = useRef(null); const searchRef = useRef(null); const includeWorkspaces = scope.workspacesEnabled !== false; @@ -89,9 +90,11 @@ export function DocumentPickerPopover({ useEffect(() => { const controller = new AbortController(); setLoading(true); + setError(null); const timer = window.setTimeout(() => { searchContextCandidates({ + publicWorkspaceSelection: scope.publicWorkspaceSelection, query, groups: scope.groups, publicWorkspaces: scope.publicWorkspaces, @@ -111,6 +114,7 @@ export function DocumentPickerPopover({ }) .catch(() => { if (!controller.signal.aborted) { + if (scope.publicWorkspaceSelection) setError('Could not load public search sources. Close and reopen Documents to retry.'); setCandidates([]); setLoading(false); } @@ -123,7 +127,7 @@ export function DocumentPickerPopover({ }; // The scope arrays are rebuilt on every bootstrap read, so this keys on the flags and // the query rather than on array identity. - }, [query, scope.groupsEnabled, scope.publicEnabled, includeWorkspaces, imagesOnly, documentsOnly]); + }, [query, scope.groupsEnabled, scope.publicEnabled, scope.publicWorkspaceSelection, includeWorkspaces, imagesOnly, documentsOnly]); // Grouped by workspace, which is how the chip row groups them too: a reader scanning for // "the contract in Marketing" is looking for the workspace first. @@ -176,6 +180,7 @@ export function DocumentPickerPopover({ /> + {error &&

{error}

} {!imagesOnly && onToggleSearchAll &&