([]);
const [loading, setLoading] = useState(true);
@@ -91,6 +104,7 @@ export function DocumentPickerPopover({
const controller = new AbortController();
setLoading(true);
setError(null);
+ setCandidates([]);
const timer = window.setTimeout(() => {
searchContextCandidates({
@@ -152,6 +166,33 @@ export function DocumentPickerPopover({
placement === 'up' ? 'bottom-full mb-2' : 'top-full mt-2',
)}
>
+ {!imagesOnly && scopeControl && (
+
+
+ Search in
+
+
scopeControl.onChange(readPublicWorkspaceSelection(event.target.value))}
+ aria-describedby={scope.publicWorkspaceSelection ? `${scopeControl.id}-description` : undefined}
+ className="w-full min-w-0 rounded-lg border border-edge bg-surface-1 px-2 py-1.5 text-sm text-text-1 focus:border-accent-ring focus:outline-none"
+ >
+ Current context
+ All {publicLabels.plural.toLowerCase()}
+ Visible {publicLabels.plural.toLowerCase()}
+
+ {scope.publicWorkspaceSelection && (
+
+ {scope.publicWorkspaceSelection === 'all'
+ ? 'Public only, including hidden workspaces.'
+ : 'Public only, using your directory visibility choices.'}
+
+ )}
+ {scopeControl.error &&
{scopeControl.error}
}
+
+ )}
Search in** to choose
+Current context, All public workspaces or Visible public workspaces. In
+Orchestrate, Documents remains under **Manual controls**, subject to the
+administrator's existing policy. Closing or hiding those controls does not
+change the retrieval scope.
+
+The Documents button indicates an active aggregate choice even with no document
+chips. Scope descriptions and conflict messages appear inside the picker.
+Blocked sends explain recovery and open the picker when permitted. Incompatible
+references are retained; disabled public workspaces still allow returning to
+Current context. Image and saved-result conflicts use their existing removal
+controls before changing scope.
+
+Scope editing is explicitly enabled only by the main personal-chat composer.
+Shared, restricted, inline-answer and image-reference editors do not gain
+global scope controls. Scope changes cancel obsolete candidate requests and
+clear their results before offering a new set.
+
+| File | Change |
+| --- | --- |
+| `application/v2_ui/src/components/chat/Composer.tsx` | Remove the standalone row, pass controlled editing, indicate active scope and provide recovery guidance. |
+| `application/v2_ui/src/components/chat/ComposerEditor.tsx` | Forward optional editing only to the intended picker and retain public-only candidate behavior. |
+| `application/v2_ui/src/components/chat/DocumentPickerPopover.tsx` | Render Search in, descriptions and errors inside Documents; clear stale candidates. |
+| `application/single_app/config.py` | Increment the application patch version. |
+| `ui_tests/fixtures/orchestration/harness_entry.tsx` | Expose the real shared editor for isolation checks. |
+
+Directory **Chat with visible**, follow-up scope persistence, latest-user-turn
+restoration, New chat reset, scope locks and public publication/access checks
+retain their existing behavior.
+
+## Validation
+
+Regression coverage uses real components, stores and request builders with
+synthetic HTTP boundaries; it does not send production chats or call models.
+
+- `functional_tests/test_v2_chat_context_picker.py` checks opt-in wiring and
+ existing context-to-request behavior.
+- `functional_tests/test_v2_public_chat_scope.py` checks authoritative public
+ resolution, access boundaries, worker isolation and replay.
+- `ui_tests/test_v2_public_chat_scope.py` checks Manual and planning dispatch,
+ picker-only editing, conflicts, recovery, streaming restrictions, editor
+ isolation, scope continuity, and styled desktop/mobile light/dark layouts.
+- `ui_tests/test_v2_public_directory.py` checks the native directory handoff
+ without visibility writes or legacy navigation.
+- `ui_tests/test_v2_chat_context_selection.py` retains ordinary context and
+ inline-reference workflows.
+- `ui_tests/test_v2_orchestration_composer.py` checks the Manual-controls
+ disclosure and administrator gating.
+
+At implementation, the following commands passed:
+
+| Command | Result |
+| --- | --- |
+| `npm --prefix .\application\v2_ui run build` | TypeScript and production build passed; existing bundle-size warning remains. |
+| `python -m pytest .\functional_tests\test_v2_chat_context_picker.py .\functional_tests\test_v2_public_chat_scope.py -q --disable-warnings` | 38 passed. |
+| `python -m pytest .\ui_tests\test_v2_public_chat_scope.py .\ui_tests\test_v2_public_directory.py .\ui_tests\test_v2_chat_context_selection.py -q` | 125 passed. |
+| `python .\ui_tests\test_v2_orchestration_composer.py` | 7 checks passed. |
+| `python .\scripts\build_docs_inventory.py` | Regenerated; no application-surface changes. |
+| `python .\functional_tests\test_docs_app_surface_coverage.py` | 7 checks passed. |
+| `python .\functional_tests\test_docs_site_quality.py` | 6 checks passed. |
+| `git -c core.whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --check` | Passed. |
+
+The UI detector reported no deterministic findings. Styled screenshots were
+reviewed at 1440px and 390px widths in light and dark themes, including long
+configured public-workspace labels. Browser checks used local Chromium through
+the existing test harness, not the production Azure application.
+
+Before this fix the selector occupied every eligible composer. Afterward it is
+available only when Documents is open; ordinary chat has no replacement banner
+or additional control above the input.
+
+See [Aggregate public chat](../features/V2_AGGREGATE_PUBLIC_CHAT.md) and
+[Chat context picker](../features/CHAT_CONTEXT_PICKER.md) for scope semantics.
diff --git a/docs/guides/use-public-workspaces.md b/docs/guides/use-public-workspaces.md
index 101d39169..022dda966 100644
--- a/docs/guides/use-public-workspaces.md
+++ b/docs/guides/use-public-workspaces.md
@@ -105,8 +105,10 @@ workspace for chat at once, saves and reuses lists of visible workspaces, and
opens classic chat over the visible ones.
From version **0.261.310**, **Chat with visible** instead opens a fresh native
-V2 chat. Its **Document search scope** stays **Visible public workspaces** for
-follow-up questions. Select **All public workspaces** in the composer to
+V2 chat. Its public scope stays **Visible public workspaces** for
+follow-up questions. From **0.261.315**, open **Documents → Search in** to
+change it; in Orchestrate, use **Manual controls → Documents** when available.
+Select **All public workspaces** inside that picker to
include hidden workspaces too, without changing visibility preferences.
Both scopes retrieve only public documents from currently chat-available
workspaces; personal and group active selections are excluded. **Current
diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md
index 07eab1680..14a2a2701 100644
--- a/docs/reference/chat-controls.md
+++ b/docs/reference/chat-controls.md
@@ -87,13 +87,18 @@ arrived during streaming. After granting access, send the message again.
### React V2 public document search scope
Implemented in version: **0.261.310** (`application/single_app/config.py`).
-When public workspaces are enabled, **Document search scope** offers
+Placement fixed in version: **0.261.315**. Open **Documents → Search in**;
+in Orchestrate, use **Manual controls → Documents** when available.
+The choices appear only inside the picker, not above the message editor:
**Current context**, **All public workspaces**, and **Visible public workspaces**.
All includes hidden, chat-available public workspaces; Visible follows your
directory curation. Neither changes your saved visibility or active workspace.
Both aggregate choices exclude personal/group retrieval and restrict Documents
and `#` browsing to public sources. Clear incompatible references before
sending, or return to Current context.
+The Documents button indicates All or Visible when its toolbar is visible.
+If manual controls are disabled in Orchestrate, switch to Manual mode to
+change the scope, or start a new chat.
The aggregate choice persists across follow-up questions and is restored from
the latest saved user turn when reopening a conversation. New chat resets it.
diff --git a/functional_tests/test_v2_chat_context_picker.py b/functional_tests/test_v2_chat_context_picker.py
index 795b309b9..ef2e50223 100644
--- a/functional_tests/test_v2_chat_context_picker.py
+++ b/functional_tests/test_v2_chat_context_picker.py
@@ -2,10 +2,11 @@
# test_v2_chat_context_picker.py
"""
Functional test for the V2 chat context picker.
-Version: 0.261.310
+Version: 0.261.315
Implemented in: 0.261.089
Independent context selection implemented in: 0.261.094
Shared editor implemented in: 0.261.096
+Scope picker placement fixed in: 0.261.315
The V2 composer shipped with a Documents button that was a plain on/off, and a
``selectedDocumentIds`` field that was declared, forwarded to both the chat
@@ -119,6 +120,20 @@ def test_the_composer_no_longer_carries_a_write_only_selection():
return True
+def test_scope_editing_is_opt_in_and_only_inside_the_documents_picker():
+ """The shared editor must not expose main-chat scope controls in unrelated editors."""
+ composer = COMPOSER_TSX.read_text(encoding="utf-8")
+ editor = EDITOR_TSX.read_text(encoding="utf-8")
+ picker = (V2_DIR / "src" / "components" / "chat" / "DocumentPickerPopover.tsx").read_text(encoding="utf-8")
+ assert "Document search scope" not in composer
+ assert "documentScopeControl={!shared" in composer
+ assert "documentScopeControl?: DocumentScopeControl" in editor
+ assert "scopeControl={!shared && !restricted && !referenceUploadsOnly" in editor
+ assert "!imagesOnly && scopeControl" in picker
+ assert "Search in" in picker
+ return True
+
+
def test_sending_clears_the_chips_with_the_text():
"""Sending clears the turn's selections and mentions without changing the next draft."""
print("\nTesting draft clearing...")
@@ -347,6 +362,7 @@ def test_the_typescript_logic_checks_pass():
test_the_version_carries_the_feature,
test_the_workspace_hand_off_stays_in_v2,
test_the_composer_no_longer_carries_a_write_only_selection,
+ test_scope_editing_is_opt_in_and_only_inside_the_documents_picker,
test_sending_clears_the_chips_with_the_text,
test_caches_are_not_poisoned_by_cancelled_requests,
test_removing_a_chip_cannot_orphan_a_shared_reference,
diff --git a/ui_tests/fixtures/orchestration/harness_entry.tsx b/ui_tests/fixtures/orchestration/harness_entry.tsx
index d055f92f6..ee20ff5d6 100644
--- a/ui_tests/fixtures/orchestration/harness_entry.tsx
+++ b/ui_tests/fixtures/orchestration/harness_entry.tsx
@@ -37,6 +37,7 @@ import { OrchestrationPlanEditorHost } from '../../../application/v2_ui/src/comp
import { MessageList } from '../../../application/v2_ui/src/components/chat/MessageList';
import { ConversationDrawer } from '../../../application/v2_ui/src/components/chat/ConversationDrawer';
import { Composer } from '../../../application/v2_ui/src/components/chat/Composer';
+import { ComposerEditor } from '../../../application/v2_ui/src/components/chat/ComposerEditor';
import { DocumentExplorer } from '../../../application/v2_ui/src/components/documents/DocumentExplorer';
import { ParticipantsPanel } from '../../../application/v2_ui/src/components/chat/ParticipantsPanel';
import { Toaster } from '../../../application/v2_ui/src/components/ui/Toaster';
@@ -176,6 +177,7 @@ type ComponentName =
| 'PlanEditorExperience'
| 'OrchestrationPlanEditorHost'
| 'ContextWorkflow'
+ | 'ComposerEditor'
| 'ParticipantsPanel'
| 'WorkflowEditorDialog'
| 'WorkflowRunHistory'
@@ -201,6 +203,7 @@ const components: Record ReactElement | null> = {
PlanEditorExperience,
OrchestrationPlanEditorHost,
ContextWorkflow,
+ ComposerEditor,
ParticipantsPanel,
WorkflowEditorDialog,
WorkflowRunHistory,
diff --git a/ui_tests/test_v2_chat_context_selection.py b/ui_tests/test_v2_chat_context_selection.py
index 29daf15c8..76902bae3 100644
--- a/ui_tests/test_v2_chat_context_selection.py
+++ b/ui_tests/test_v2_chat_context_selection.py
@@ -1,7 +1,7 @@
# test_v2_chat_context_selection.py
"""
Browser regressions for V2 context selection and explicitly chosen inline mentions.
-Version: 0.261.310
+Version: 0.261.315
Implemented in: 0.261.094
Single orchestration contract updated in: 0.261.181 (the React V2 branch's 0.261.139)
Shared editor and prompt dispatch regression coverage added in: 0.261.096
@@ -346,7 +346,7 @@ def expect_pills(page: Page, *labels):
def open_picker(page: Page):
- toolbar = page.get_by_title(re.compile(r"^Documents(?: · \d+)?$"))
+ toolbar = page.get_by_title(re.compile(r"^Documents(?: · .+)?$"))
if not toolbar.is_visible():
page.get_by_title("Manual controls", exact=True).click()
toolbar.click()
diff --git a/ui_tests/test_v2_orchestration_composer.py b/ui_tests/test_v2_orchestration_composer.py
index 976de21c3..ae858a5a3 100644
--- a/ui_tests/test_v2_orchestration_composer.py
+++ b/ui_tests/test_v2_orchestration_composer.py
@@ -1,7 +1,7 @@
# test_v2_orchestration_composer.py
"""
UI test for the V2 Composer's orchestration mode: the toggle, the manual-controls disclosure.
-Version: 0.261.137
+Version: 0.261.315
Implemented in: 0.261.085; Image requests orchestration image proposals since 0.261.132;
the Orchestrate model picker folds under Manual controls since 0.261.137
@@ -274,12 +274,49 @@ def test_orchestration_image_control_requests_image_proposals():
return False
+def test_public_scope_stays_behind_manual_controls():
+ """Aggregate retrieval remains active while its controls are collapsed or admin-hidden."""
+ page = _PAGE
+ page.route("**/api/public_workspace_documents?**", lambda route: route.fulfill(json={"documents": []}))
+ page.route("**/api/public_workspace_documents/tags?**", lambda route: route.fulfill(json={"tags": []}))
+ page.evaluate(_SEED_COMPOSER, {
+ "features": _features(enable_public_workspaces=True),
+ "orchestration": _orchestration(),
+ })
+ page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().setPublicWorkspaceSelection('visible')")
+ assert not _has(page, 'select'), "scope must not appear above the message editor"
+ assert not _has(page, '[title^="Documents"]')
+ page.get_by_title("Manual controls", exact=True).click()
+ documents = page.get_by_title("Documents · Visible public workspaces", exact=True)
+ documents.wait_for(state="visible")
+ assert documents.get_attribute("aria-pressed") == "true"
+ assert not _has(page, 'select'), "expanding manual tools alone must not expose the scope field"
+ documents.click()
+ scope = page.get_by_label("Search in", exact=True)
+ scope.wait_for(state="visible")
+ assert scope.input_value() == "visible"
+ page.get_by_role("button", name="Done", exact=True).click()
+ page.get_by_title("Manual controls", exact=True).click()
+ assert not _has(page, '[title^="Documents"]')
+ selection = page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().publicWorkspaceSelection")
+ assert selection == "visible", "disclosure must never change the search mode"
+ page.evaluate(_SEED_COMPOSER, {
+ "features": _features(enable_public_workspaces=True),
+ "orchestration": _orchestration(show_manual_controls=False),
+ })
+ assert not _has(page, '[title="Manual controls"]')
+ assert not _has(page, '[title^="Documents"]')
+ assert not _has(page, 'select')
+ return True
+
+
PAGE_TESTS = [
test_toggle_hidden_unless_feature_and_switch_are_on,
test_toggle_is_on_by_default_with_controls_collapsed,
test_turning_off_restores_the_classic_composer,
test_disclosure_restores_the_manual_controls,
test_orchestration_image_control_requests_image_proposals,
+ test_public_scope_stays_behind_manual_controls,
]
diff --git a/ui_tests/test_v2_public_chat_scope.py b/ui_tests/test_v2_public_chat_scope.py
index 9e3de4777..d698036a2 100644
--- a/ui_tests/test_v2_public_chat_scope.py
+++ b/ui_tests/test_v2_public_chat_scope.py
@@ -1,13 +1,15 @@
# test_v2_public_chat_scope.py
"""
Native aggregate public chat, using the real React composer, stores and router.
-Version: 0.261.311
+Version: 0.261.315
Implemented in: 0.261.310
+Scope controls moved into Documents in: 0.261.315
HTTP boundaries reuse the context workflow harness; no Azure or model calls are made.
"""
import re
+from pathlib import Path
from urllib.parse import parse_qs, urlsplit
import pytest
@@ -32,6 +34,19 @@ def send(page, message, dispatch="chat"):
return sent.value.post_data_json
+def select_scope(page, mode):
+ open_picker(page)
+ page.get_by_label("Search in", exact=True).select_option(mode)
+ page.get_by_role("button", name="Done", exact=True).click()
+ expect(page.get_by_label("Search in", exact=True)).to_have_count(0)
+
+
+def expect_scope(page, mode):
+ open_picker(page)
+ expect(page.get_by_label("Search in", exact=True)).to_have_value(mode)
+ page.get_by_role("button", name="Done", exact=True).click()
+
+
@pytest.mark.parametrize("mode", ["all", "visible"])
@pytest.mark.parametrize("dispatch", ["chat", "plan"])
def test_both_dispatches_are_public_only_despite_stored_active_context(context_page, mode, dispatch):
@@ -43,7 +58,7 @@ def test_both_dispatches_are_public_only_despite_stored_active_context(context_p
store.setState({data: {...data, scope: {...data.scope,
active_group_id: 'group-1', active_public_workspace_id: 'stale-public'}}});
}""")
- page.get_by_label("Document search scope").select_option(mode)
+ select_scope(page, mode)
body = send(page, "Find public policy", dispatch)
scope = body if dispatch == "chat" else body["seeds"] if "seeds" in body else body
assert scope["public_workspace_selection"] == mode
@@ -51,7 +66,7 @@ def test_both_dispatches_are_public_only_despite_stored_active_context(context_p
assert scope["active_public_workspace_ids"] == []
if dispatch == "chat":
assert body["chat_type"] == "user" and body["hybrid_search"]
- expect(page.get_by_label("Document search scope")).to_have_value(mode)
+ expect_scope(page, mode)
@pytest.mark.parametrize("mode", ["all", "visible"])
@@ -73,7 +88,7 @@ def public_sources(route):
page.route("**/api/public_workspace_documents?**", public_sources)
page.route("**/api/public_workspace_documents/tags?**", public_sources)
mount_workflow(page)
- page.get_by_label("Document search scope").select_option(mode)
+ select_scope(page, mode)
open_picker(page)
expect(page.get_by_role("button", name=re.compile("^Travel policy"))).to_be_visible()
expect(page.get_by_role("button", name=re.compile("^Quarterly brief"))).to_have_count(0)
@@ -94,12 +109,13 @@ def test_non_public_draft_references_block_send_until_context_is_changed(context
page, api = context_page
mount_workflow(page)
pick_context(page, "Quarterly brief")
- page.get_by_label("Document search scope").select_option("all")
+ select_scope(page, "all")
page.get_by_role("textbox", name="Message", exact=True).fill("Question")
page.get_by_role("button", name="Send message", exact=True).click()
expect(page.get_by_role("alert")).to_contain_text("Clear personal or group references")
assert not any(method == "POST" for method, path in api.requests)
- page.get_by_label("Document search scope").select_option("")
+ page.get_by_label("Search in", exact=True).select_option("")
+ page.get_by_role("button", name="Done", exact=True).click()
body = send(page, "Question")
assert "public_workspace_selection" not in body
assert body["selected_document_ids"] == ["personal-brief"]
@@ -109,7 +125,7 @@ def test_selected_workspace_is_not_silently_broadened_by_aggregate_mode(context_
page, api = context_page
mount_workflow(page)
pick_context(page, "Handbook")
- page.get_by_label("Document search scope").select_option("all")
+ select_scope(page, "all")
page.get_by_role("textbox", name="Message", exact=True).fill("Question")
page.get_by_role("button", name="Send message", exact=True).click()
expect(page.get_by_role("alert")).to_contain_text("Clear whole-workspace references")
@@ -119,7 +135,7 @@ def test_selected_workspace_is_not_silently_broadened_by_aggregate_mode(context_
def test_store_dispatch_inherits_the_persistent_public_scope(context_page):
page, api = context_page
mount_workflow(page)
- page.get_by_label("Document search scope").select_option("all")
+ select_scope(page, "all")
with page.expect_request(lambda request: request.method == "POST" and request.url.endswith(STREAM_PATHS["chat"])) as sent:
page.evaluate("""() => window.OrchHarness.stores.chat.useChatStore.getState().sendMessage(
'Public follow-up', {documentSearch: false, webSearch: false, imageGeneration: false,
@@ -132,16 +148,14 @@ def test_store_dispatch_inherits_the_persistent_public_scope(context_page):
def test_scope_change_refreshes_candidates_and_picker_failures_are_explicit(context_page):
page, api = context_page
mount_workflow(page)
- page.get_by_label("Document search scope").select_option("all")
+ select_scope(page, "all")
open_picker(page)
expect(page.get_by_role("button", name=re.compile("^Travel policy"))).to_be_visible()
- page.get_by_role("button", name="Done", exact=True).click()
def refuse(route):
api.expected_resource_errors.add(("/api/public_workspace_documents", 409))
route.fulfill(status=409, json={"error": "No public workspaces are available for this scope."})
page.route("**/api/public_workspace_documents?**", refuse)
- page.get_by_label("Document search scope").select_option("visible")
- open_picker(page)
+ page.get_by_label("Search in", exact=True).select_option("visible")
expect(page.get_by_role("alert")).to_contain_text("Could not load public search sources")
expect(page.get_by_role("button", name=re.compile("^Travel policy"))).to_have_count(0)
@@ -151,14 +165,14 @@ def test_scope_only_launch_is_fresh_consumed_once_and_survives_first_id(context_
page, api = context_page
mount_workflow(page, "/chat?public_workspace_selection=visible", strict_mode=strict_mode, native_chat=True)
expect(page.get_by_label("Current route")).to_have_text("/chat")
- expect(page.get_by_label("Document search scope")).to_have_value("visible")
+ expect_scope(page, "visible")
first = send(page, "First public question")
assert first["conversation_id"] == HANDOFF_CONVERSATION_ID
followup = send(page, "Follow-up question")
assert followup["public_workspace_selection"] == "visible"
assert followup["conversation_id"] == HANDOFF_CONVERSATION_ID
page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().startNewConversation()")
- expect(page.get_by_label("Document search scope")).to_have_value("")
+ expect_scope(page, "")
def test_switching_and_reload_restore_only_the_latest_user_turn(context_page):
@@ -187,4 +201,209 @@ def test_switching_and_reload_restore_only_the_latest_user_turn(context_page):
page.route("**/api/v2/orchestration/runs?**", lambda route: route.fulfill(json={"runs": []}))
for identifier, mode in [("aggregate-saved", "all"), ("ordinary-saved", ""), ("aggregate-saved", "all")]:
page.evaluate("(id) => window.OrchHarness.stores.chat.useChatStore.getState().selectConversation(id)", identifier)
- expect(page.get_by_label("Document search scope")).to_have_value(mode)
+ expect_scope(page, mode)
+
+
+@pytest.mark.parametrize("orchestration", [False, True])
+def test_scope_is_only_in_documents_and_opening_it_preserves_current_context(context_page, orchestration):
+ page, api = context_page
+ mount_workflow(page, orchestration=orchestration)
+ expect(page.get_by_label("Document search scope")).to_have_count(0)
+ expect(page.get_by_label("Search in", exact=True)).to_have_count(0)
+ open_picker(page)
+ expect(page.get_by_label("Search in", exact=True)).to_have_value("")
+ expect(page.get_by_role("searchbox", name="Search documents", exact=True)).to_be_focused()
+ page.get_by_role("searchbox", name="Search documents", exact=True).press("Escape")
+ expect(page.get_by_label("Search in", exact=True)).to_have_count(0)
+ expect(page.get_by_role("textbox", name="Message", exact=True)).to_be_focused()
+ dispatch = "plan" if orchestration else "chat"
+ body = send(page, "Ordinary question", dispatch)
+ scope = body.get("seeds", body) if orchestration else body
+ assert "public_workspace_selection" not in scope
+ assert scope["doc_scope"] == "personal"
+ assert scope["active_group_ids"] == [] and scope["active_public_workspace_ids"] == []
+ if not orchestration:
+ assert not body["hybrid_search"]
+
+
+def test_disabled_public_workspaces_allow_escape_without_dropping_references(context_page):
+ page, api = context_page
+ mount_workflow(page)
+ pick_context(page, "Travel policy")
+ select_scope(page, "visible")
+ page.evaluate("""() => {
+ const store = window.OrchHarness.stores.bootstrap.useBootstrapStore;
+ const data = store.getState().data;
+ store.setState({data: {...data, features: {...data.features, enable_public_workspaces: false}}});
+ }""")
+ page.get_by_role("textbox", name="Message", exact=True).fill("Question")
+ page.get_by_role("button", name="Send message", exact=True).click()
+ expect(page.get_by_role("alert")).to_contain_text("Public workspaces are currently disabled")
+ expect(page.get_by_label("Search in", exact=True).locator('option[value="all"]')).to_be_disabled()
+ expect(page.get_by_role("button", name="Remove Travel policy", exact=True)).to_be_visible()
+ page.get_by_label("Search in", exact=True).select_option("")
+ page.get_by_role("button", name="Done", exact=True).click()
+ body = send(page, "Question")
+ assert "public_workspace_selection" not in body
+ assert body["selected_document_ids"] == ["public-policy"]
+
+
+def test_admin_hidden_manual_controls_do_not_expose_scope_and_offer_manual_recovery(context_page):
+ page, api = context_page
+ mount_workflow(page, orchestration=True)
+ page.evaluate("() => window.OrchHarness.mount('mount-b', 'Toaster', {})")
+ page.evaluate("""() => {
+ const H = window.OrchHarness;
+ H.stores.chat.useChatStore.getState().setPublicWorkspaceSelection('all');
+ const store = H.stores.bootstrap.useBootstrapStore;
+ const data = store.getState().data;
+ store.setState({data: {...data, features: {...data.features, enable_public_workspaces: false},
+ orchestration: {...data.orchestration, show_manual_controls: false}}});
+ }""")
+ expect(page.get_by_title("Manual controls", exact=True)).to_have_count(0)
+ expect(page.get_by_title(re.compile(r"^Documents"))).to_have_count(0)
+ page.get_by_role("textbox", name="Message", exact=True).fill("Question")
+ page.get_by_role("button", name="Send message", exact=True).click()
+ expect(page.get_by_text(re.compile("Switch to Manual mode, then open Documents"))).to_be_visible()
+ expect(page.get_by_label("Search in", exact=True)).to_have_count(0)
+ assert not any(method == "POST" for method, path in api.requests)
+ page.get_by_title("Orchestrate", exact=True).click()
+ open_picker(page)
+ page.get_by_label("Search in", exact=True).select_option("")
+ page.get_by_role("button", name="Done", exact=True).click()
+ body = send(page, "Question")
+ assert "public_workspace_selection" not in body
+
+
+@pytest.mark.parametrize("theme,width,height", [
+ ("light", 1440, 900), ("dark", 1440, 900),
+ ("light", 390, 844), ("dark", 390, 844),
+])
+def test_scope_picker_layout_and_active_summary(context_page, theme, width, height):
+ page, api = context_page
+ page.set_viewport_size({"width": width, "height": height})
+ assets = Path(__file__).resolve().parents[1] / "application" / "single_app" / "static" / "v2" / "assets"
+ stylesheets = list(assets.glob("*.css"))
+ assert len(stylesheets) == 1, "Build V2 before checking its production styling."
+ page.add_style_tag(content=stylesheets[0].read_text(encoding="utf-8"))
+ page.add_style_tag(content="#mount-a { display: flex; flex-direction: column; justify-content: end; height: 100dvh; }")
+ mount_workflow(page, orchestration=True)
+ page.evaluate("""(theme) => {
+ document.documentElement.classList.toggle('dark', theme === 'dark');
+ const store = window.OrchHarness.stores.bootstrap.useBootstrapStore;
+ const data = store.getState().data;
+ store.setState({data: {...data, settings: {...data.settings, public_workspace_labels: {
+ singular: 'Public knowledge collection', plural: 'Public knowledge collections with long configured names',
+ }}}});
+ }""", theme)
+ expect(page.get_by_label("Search in", exact=True)).to_have_count(0)
+ expect(page.get_by_title(re.compile("^Documents"))).to_have_count(0)
+ select_scope(page, "visible")
+ documents = page.get_by_title(re.compile(r"^Documents · Visible"))
+ expect(documents).to_have_attribute("aria-pressed", "true")
+ page.get_by_title("Manual controls", exact=True).click()
+ expect(documents).to_have_count(0)
+ expect(page.get_by_label("Search in", exact=True)).to_have_count(0)
+ open_picker(page)
+ expect(page.get_by_label("Search in", exact=True)).to_have_value("visible")
+ fits = page.locator("[data-context-picker]").evaluate("""(node) => {
+ const rect = node.getBoundingClientRect();
+ return rect.left >= 0 && rect.right <= window.innerWidth
+ && rect.top >= 0 && rect.bottom <= window.innerHeight
+ && document.documentElement.scrollWidth <= window.innerWidth;
+ }""")
+ assert fits
+ expect(page.get_by_role("button", name=re.compile("^Travel policy"))).to_be_visible()
+ artifacts = Path(__file__).resolve().parent / "artifacts" / "document-scope"
+ artifacts.mkdir(parents=True, exist_ok=True)
+ page.screenshot(path=str(artifacts / f"picker-{theme}-{width}.png"))
+
+
+@pytest.mark.parametrize("restriction", ["shared", "restricted", "referenceUploadsOnly", "not-opted-in"])
+def test_unrelated_editors_cannot_edit_the_global_chat_scope(context_page, restriction):
+ page, api = context_page
+ mount_workflow(page)
+ page.evaluate("""(restriction) => {
+ const H = window.OrchHarness;
+ H.unmount('mount-a');
+ const props = {
+ id: 'isolated-editor', label: 'Answer', conversationId: null,
+ draft: {text: '', contextItems: [], attachedPrompt: null, promptValues: {}, uploads: [], imageReferences: []},
+ onChange: () => {}, pickerOpen: true, allowContext: true,
+ documentScopeControl: {
+ id: 'isolated-scope', publicEnabled: true, disabled: false,
+ onChange: (selection) => H.stores.chat.useChatStore.getState().setPublicWorkspaceSelection(selection),
+ },
+ };
+ if (restriction === 'not-opted-in') delete props.documentScopeControl;
+ else props[restriction] = true;
+ H.mount('mount-a', 'ComposerEditor', props);
+ }""", restriction)
+ expect(page.locator("[data-context-picker]")).to_be_visible()
+ expect(page.get_by_label("Search in", exact=True)).to_have_count(0)
+ selection = page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().publicWorkspaceSelection")
+ assert selection is None
+
+
+def test_scope_cannot_change_while_streaming(context_page):
+ page, api = context_page
+ mount_workflow(page)
+ select_scope(page, "visible")
+ open_picker(page)
+ page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.setState({streaming: true})")
+ expect(page.get_by_label("Search in", exact=True)).to_be_disabled()
+ page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.setState({streaming: false})")
+ expect(page.get_by_label("Search in", exact=True)).to_be_enabled()
+ expect(page.get_by_label("Search in", exact=True)).to_have_value("visible")
+
+
+def test_an_open_picker_closes_when_admin_hides_manual_controls(context_page):
+ page, api = context_page
+ mount_workflow(page, orchestration=True)
+ select_scope(page, "all")
+ open_picker(page)
+ page.evaluate("""() => {
+ const store = window.OrchHarness.stores.bootstrap.useBootstrapStore;
+ const data = store.getState().data;
+ store.setState({data: {...data, orchestration: {...data.orchestration, show_manual_controls: false}}});
+ }""")
+ expect(page.locator("[data-context-picker]")).to_have_count(0)
+ selection = page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().publicWorkspaceSelection")
+ assert selection == "all"
+
+
+@pytest.mark.parametrize("conflict", ["image", "saved-result"])
+def test_incompatible_modes_explain_recovery_without_a_standalone_scope_control(context_page, conflict):
+ page, api = context_page
+ mount_workflow(page)
+ select_scope(page, "all")
+ page.evaluate("() => window.OrchHarness.mount('mount-b', 'Toaster', {})")
+ if conflict == "image":
+ page.evaluate("""() => {
+ const store = window.OrchHarness.stores.bootstrap.useBootstrapStore;
+ const data = store.getState().data;
+ store.setState({data: {...data, features: {...data.features, enable_image_generation: true}}});
+ }""")
+ page.get_by_title("Image", exact=True).click()
+ recovery = "Turn off Image"
+ else:
+ page.evaluate("""() => {
+ const store = window.OrchHarness.stores.chat.useChatStore;
+ store.setState({analysisResultContext: {
+ conversation_id: store.getState().activeConversationId, message_id: 'saved-result',
+ result_sha256: 'a'.repeat(64),
+ }});
+ }""")
+ recovery = "Remove the saved result"
+ page.get_by_role("textbox", name="Message", exact=True).fill("Question")
+ page.get_by_role("button", name="Send message", exact=True).click()
+ expect(page.get_by_text(re.compile(recovery))).to_be_visible()
+ expect(page.get_by_label("Search in", exact=True)).to_have_count(0)
+ assert not any(method == "POST" for method, path in api.requests)
+ if conflict == "image":
+ page.get_by_title("Image", exact=True).click()
+ else:
+ page.get_by_role("button", name="Remove saved analysis context", exact=True).click()
+ select_scope(page, "")
+ body = send(page, "Question")
+ assert "public_workspace_selection" not in body
diff --git a/ui_tests/test_v2_public_directory.py b/ui_tests/test_v2_public_directory.py
index bc8ce0c87..f91114734 100644
--- a/ui_tests/test_v2_public_directory.py
+++ b/ui_tests/test_v2_public_directory.py
@@ -1,7 +1,7 @@
# test_v2_public_directory.py
"""
Production-SPA coverage for the native V2 public workspace directory page.
-Version: 0.261.310
+Version: 0.261.315
Implemented in: 0.261.175
The Create dialog holds keyboard focus and hands it back on close: 0.261.188
@@ -804,8 +804,15 @@ def test_chat_with_visible_opens_chat_and_writes_nothing(public_directory_ui, th
exact=False,
)).to_be_visible()
page.get_by_role("button", name="Chat with visible", exact=True).click()
- expect(page.get_by_label("Document search scope", exact=True)).to_have_value("visible")
+ expect(page.get_by_label("Document search scope", exact=True)).to_have_count(0)
+ expect(page.get_by_label("Search in", exact=True)).to_have_count(0)
expect(page).to_have_url(re.compile(r"/v2/chat$"))
+ documents = page.get_by_title(re.compile(r"^Documents · Visible"))
+ if not documents.is_visible():
+ page.get_by_title("Manual controls", exact=True).click()
+ expect(documents).to_have_attribute("aria-pressed", "true")
+ documents.click()
+ expect(page.get_by_label("Search in", exact=True)).to_have_value("visible")
assert not ui.classic_visits
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert not visibility_writes(ui), "Chat with visible must not write any visibility preference."