From a0757d3b0066754c80690b257f1b1fcd40a83fc3 Mon Sep 17 00:00:00 2001 From: witbrock Date: Wed, 16 Sep 2026 12:57:51 -0400 Subject: [PATCH] Hide imported conversations by default with a display toggle --- .../external_conversation_import.md | 11 +++++++ src/backend/server/routes/message_routes.py | 1 + src/backend/server/routes/von_routes.py | 5 +++ src/backend/services/chat_history_service.py | 12 +++++++ .../conversation_catalogue_service.py | 9 ++++-- .../conversation_management_service.py | 6 ++++ .../services/conversation_search_service.py | 9 ++++++ .../web/von_interface/static/js/chatTab.js | 3 +- .../js/components/conversationCatalogue.js | 32 ++++++++++++++----- tests/backend/test_conversation_catalogue.py | 18 +++++++++++ .../backend/test_history_sessions_endpoint.py | 26 +++++++++++++++ .../unifiedConversationCatalogue.test.js | 23 +++++++++++++ 12 files changed, 143 insertions(+), 12 deletions(-) diff --git a/docs/engineering/external_conversation_import.md b/docs/engineering/external_conversation_import.md index e958ea911..59bab94fe 100644 --- a/docs/engineering/external_conversation_import.md +++ b/docs/engineering/external_conversation_import.md @@ -146,6 +146,17 @@ projection with an explicit loss count. The visible projection is bounded to message. Any truncation or omission is reported in the preview and stored loss report rather than hidden. +## Display in Conversations + +Imported conversations are hidden from the Conversations tray and its search +results by default. Select **Show imported** in the tray filters to include them +for the current page session. This uses the existing +`origin_kind=external_conversation_import` provenance marker and filters owned +history before pagination, so imports do not crowd ordinary conversations out +of the list. Direct links still open imported conversations; their contents, +dates, read-only status, and raw-source custody are unchanged. This display +filter does not archive or delete anything. + ## Durable batch lifecycle A batch progresses through `planning`, `ready`, `running`, and a terminal diff --git a/src/backend/server/routes/message_routes.py b/src/backend/server/routes/message_routes.py index 239731865..228494e03 100644 --- a/src/backend/server/routes/message_routes.py +++ b/src/backend/server/routes/message_routes.py @@ -994,6 +994,7 @@ def unified_conversation_catalogue(): limit=request.args.get("limit", 100, type=int), cursor=request.args.get("cursor"), all_contexts=request.args.get("all_contexts") == "true", + include_imported=request.args.get("include_imported") == "true", ) ) except (ValueError, PermissionError) as exc: diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py index d9805edc4..bc3e63df2 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -17941,6 +17941,7 @@ def _resolve_authorised_active_session_id( or request.args.get("agent_created_visibility") or chat_history_service.CHAT_SESSION_AGENT_VISIBILITY_INCLUDE ) + include_imported = request.args.get("include_imported", "false").lower() == "true" keep_newest_agent_created = request.args.get("keep_newest_agent_created") recent_window_days = request.args.get("recent_window_days", type=int) if recent_window_days is not None: @@ -17990,6 +17991,7 @@ def _resolve_authorised_active_session_id( limit=limit, namespace=namespace, include_legacy=include_legacy, + include_imported=include_imported, summary_mode=summary_mode, agent_visibility=agent_visibility, keep_newest_agent_created=keep_newest_agent_created, @@ -18004,6 +18006,7 @@ def _resolve_authorised_active_session_id( cutoff=cutoff, namespace=namespace, include_legacy=include_legacy, + include_imported=include_imported, agent_visibility=agent_visibility, ) ) @@ -18222,6 +18225,7 @@ def _resolve_authorised_active_session_id( session_row for session_row in (sessions + shared_sessions) if isinstance(session_row, dict) + and (include_imported or session_row.get("origin_kind") != "external_conversation_import") ] try: combined = conversation_management_service.apply_conversation_preferences( @@ -20371,6 +20375,7 @@ def search_conversations(): sort=request.args.get("sort", "relevance"), page_size=request.args.get("page_size", default=20, type=int), cursor=request.args.get("cursor"), + include_imported=request.args.get("include_imported", "false").lower() == "true", include_hidden=request.args.get("include_hidden", "false").lower() == "true", trashed_only=request.args.get("trashed_only", "false").lower() == "true", diff --git a/src/backend/services/chat_history_service.py b/src/backend/services/chat_history_service.py index 9210c2746..36b8ea51b 100644 --- a/src/backend/services/chat_history_service.py +++ b/src/backend/services/chat_history_service.py @@ -5456,6 +5456,7 @@ def get_chat_history_sessions_older_than_count( cutoff: datetime, namespace: Optional[str] = None, include_legacy: bool = True, + include_imported: bool = True, agent_visibility: Any = CHAT_SESSION_AGENT_VISIBILITY_INCLUDE, ) -> int: """Count actor-owned, untrashed sessions older than a recency cutoff. @@ -5488,6 +5489,8 @@ def get_chat_history_sessions_older_than_count( namespace=namespace, include_legacy=include_legacy, ) + if not include_imported: + query["origin_kind"] = {"$ne": "external_conversation_import"} query["trashed_at"] = None clauses: list[Dict[str, Any]] = [ { @@ -5845,6 +5848,7 @@ def get_chat_history_session_summaries_page( *, namespace: Optional[str] = None, include_legacy: bool = True, + include_imported: bool = True, page_size: int = 100, position: Optional[Mapping[str, Any]] = None, ) -> Dict[str, Any]: @@ -5870,6 +5874,8 @@ def get_chat_history_session_summaries_page( namespace=namespace, include_legacy=include_legacy, ) + if not include_imported: + base_query["origin_kind"] = {"$ne": "external_conversation_import"} epoch = datetime(1970, 1, 1, tzinfo=timezone.utc) pipeline: List[Dict[str, Any]] = [ {"$match": base_query}, @@ -6021,6 +6027,7 @@ def _load_chat_history_session_summaries( *, namespace: Optional[str] = None, include_legacy: bool = True, + include_imported: bool = True, summary_mode: str = "full", metadata_query_limit: int | None = None, ) -> List[Dict[str, Any]]: @@ -6040,6 +6047,9 @@ def _load_chat_history_session_summaries( user_id=user_id, namespace=namespace, include_legacy=include_legacy ) + if not include_imported: + query["origin_kind"] = {"$ne": "external_conversation_import"} + if light_mode: try: summaries = _get_chat_history_session_summaries_metadata_only( @@ -6221,6 +6231,7 @@ def get_chat_history_session_summaries_result( *, namespace: Optional[str] = None, include_legacy: bool = True, + include_imported: bool = True, summary_mode: str = "full", agent_visibility: Any = CHAT_SESSION_AGENT_VISIBILITY_INCLUDE, keep_newest_agent_created: Any = True, @@ -6240,6 +6251,7 @@ def get_chat_history_session_summaries_result( user_id, namespace=namespace, include_legacy=include_legacy, + include_imported=include_imported, summary_mode=summary_mode, metadata_query_limit=metadata_query_limit, ) diff --git a/src/backend/services/conversation_catalogue_service.py b/src/backend/services/conversation_catalogue_service.py index b56ed466e..b6bdb3695 100644 --- a/src/backend/services/conversation_catalogue_service.py +++ b/src/backend/services/conversation_catalogue_service.py @@ -19,7 +19,7 @@ log = logging.getLogger(__name__) -def _chat_page(actor, namespace, organisation, limit, before): +def _chat_page(actor, namespace, organisation, limit, before, include_imported=False): owned = chats.build_chat_history_query( user_id=actor, namespace=namespace, include_legacy=False ) @@ -48,6 +48,8 @@ def _chat_page(actor, namespace, organisation, limit, before): *({"user_id": owner, "session_id": sid} for owner, sid in authorised), ] query = {"$or": clauses, "trashed_at": None} + if not include_imported: + query["origin_kind"] = {"$ne": "external_conversation_import"} collection = chats.get_chat_history_collection_service(read_only=True) if collection is None: raise RuntimeError("Conversations unavailable.") @@ -124,7 +126,7 @@ def _chat_page(actor, namespace, organisation, limit, before): def list_catalogue( - actor, *, namespace, organisation=None, limit=100, cursor=None, all_contexts=False + actor, *, namespace, organisation=None, limit=100, cursor=None, all_contexts=False, include_imported=False ): if not actor or not namespace: raise PermissionError("Authenticated conversation context required.") @@ -134,6 +136,7 @@ def list_catalogue( "namespace": namespace, "organisation": organisation, "all_contexts": all_contexts, + "include_imported": include_imported, } before = None if cursor: @@ -143,7 +146,7 @@ def list_catalogue( before = decoded["position"] rows, coverage, more = [], {}, False try: - chat_rows, chat_more = _chat_page(actor, namespace, organisation, limit, before) + chat_rows, chat_more = _chat_page(actor, namespace, organisation, limit, before, include_imported) rows.extend(chat_rows) more |= chat_more coverage["conversations"] = True diff --git a/src/backend/services/conversation_management_service.py b/src/backend/services/conversation_management_service.py index 42c8986f8..33e791767 100644 --- a/src/backend/services/conversation_management_service.py +++ b/src/backend/services/conversation_management_service.py @@ -615,6 +615,7 @@ def list_actor_conversations( organisation_concept_id: str | None = None, limit: int = 20, include_hidden: bool = False, + include_imported: bool = True, include_trashed: bool = False, cursor: str | None = None, name_present: bool | None = None, @@ -636,6 +637,7 @@ def list_actor_conversations( "namespace": namespace, "organisation_concept_id": _normalise_concept_id(organisation_concept_id), "include_hidden": include_hidden, + "include_imported": include_imported, "include_trashed": include_trashed, "name_present": name_present, # Adapters may bind additional semantics without wrapping this already @@ -712,6 +714,8 @@ def _append_visible(rows: Iterable[Mapping[str, Any]]) -> None: projected = _project_actor_conversation_contract( actor_user_id=actor, row=row ) + if not include_imported and projected.get("origin_kind") == "external_conversation_import": + continue if projected.get("trashed") is True: trashed_count += 1 if not include_trashed: @@ -731,6 +735,7 @@ def _append_visible(rows: Iterable[Mapping[str, Any]]) -> None: actor, namespace=namespace, include_legacy=True, + include_imported=include_imported, page_size=safe_limit, position=position, ) @@ -934,6 +939,7 @@ def _append_visible(rows: Iterable[Mapping[str, Any]]) -> None: "count": len(conversations), "limit": safe_limit, "include_hidden": include_hidden, + "include_imported": include_imported, "include_trashed": include_trashed, "hidden_count": hidden_count, "trashed_count": trashed_count, diff --git a/src/backend/services/conversation_search_service.py b/src/backend/services/conversation_search_service.py index 00f5f9d71..679bcfb0d 100644 --- a/src/backend/services/conversation_search_service.py +++ b/src/backend/services/conversation_search_service.py @@ -127,6 +127,7 @@ def _lexical_candidates( namespace: str | None, query: str, trashed_only: bool = False, + include_imported: bool = True, candidate_limit: int = _MAX_CANDIDATES, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: collection = chat_history_service.get_chat_history_collection_service( @@ -142,6 +143,8 @@ def _lexical_candidates( mongo_query["trashed_at"] = ( {"$exists": True, "$ne": None} if trashed_only else None ) + if not include_imported: + mongo_query["origin_kind"] = {"$ne": "external_conversation_import"} mongo_query["$text"] = {"$search": query} projection = { "_id": 0, @@ -223,6 +226,7 @@ def search_actor_conversations( page_size: int = 20, cursor: str | None = None, include_hidden: bool = False, + include_imported: bool = True, trashed_only: bool = False, ) -> dict[str, Any]: """Search accessible conversations and return a signed keyset continuation.""" @@ -274,6 +278,7 @@ def search_actor_conversations( "filters": clean_filters, "sort": sort_mode, "include_hidden": include_hidden, + "include_imported": include_imported, "trashed_only": trashed_only, } cursor_context = hashlib.sha256( @@ -291,6 +296,7 @@ def search_actor_conversations( organisation_concept_id=organisation_concept_id, limit=safe_page_size, include_hidden=include_hidden, + include_imported=include_imported, include_trashed=trashed_only, # The source cursor is already signed and binds the actor, # namespace, organisation, and list filters. Returning it @@ -382,6 +388,7 @@ def search_actor_conversations( organisation_concept_id=organisation_concept_id, limit=100, include_hidden=include_hidden, + include_imported=include_imported, include_trashed=trashed_only, cursor=accessible_cursor, ) @@ -470,6 +477,7 @@ def search_actor_conversations( query=query_text, trashed_only=trashed_only, candidate_limit=per_scope_candidate_limit, + include_imported=include_imported, ) lexical_rows.extend(scope_rows) lexical_scope_states.append(scope_state) @@ -653,6 +661,7 @@ def search_actor_conversations( "filters": clean_filters, "sort": sort_mode, "include_hidden": include_hidden, + "include_imported": include_imported, "trashed_only": trashed_only, "index_version": chat_history_service.CONVERSATION_SEARCH_INDEX_VERSION, "index_generation": index_generation, diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index ddbbcc6d8..bd0e1bbb4 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -7,7 +7,7 @@ import { initialiseCompactChatComposer, isCompactComposer, resizeCompactDraft, s import { updateExecutionCost } from './components/executionCost.js'; import { canUseWorkflowStudio } from './workflowStudioAccess.js'; import { createChatSteeringControls } from './components/chatSteeringControls.js'; -import { directConversationRows, activeMessageConversationId, showChatConversation, resetConversationCatalogue, renderMessageConversationRow, mountCatalogueControls, initialiseConversationCatalogue, selectMessageConversation, filterCatalogueRows, catalogueSearchActive, rankCatalogueSearchRows } from './components/conversationCatalogue.js'; +import { directConversationRows, activeMessageConversationId, showChatConversation, resetConversationCatalogue, renderMessageConversationRow, mountCatalogueControls, initialiseConversationCatalogue, selectMessageConversation, filterCatalogueRows, importedConversationsVisible, catalogueSearchActive, rankCatalogueSearchRows } from './components/conversationCatalogue.js'; import { catalogueHasMore } from './components/conversationCatalogue.js'; import { profileButton, participantAvatar, participantIdentityAvatar } from './components/participantProfile.js'; import { importedConversationAvatar } from './components/importedConversationAvatar.js'; @@ -1608,6 +1608,7 @@ function buildChatSessionTabsFetchUrl() { const conversationHistorySettings = loadConversationHistorySettings((key) => safeLocalStorageGet(key)); params.set('limit', String(CHAT_SESSION_TABS_FETCH_LIMIT)); params.set('summary', 'light'); + params.set('include_imported', String(importedConversationsVisible())); params.set('agent_visibility', showAgentCreatedSessions ? 'include' : 'exclude'); params.set('keep_newest_agent_created', 'true'); params.set('recent_window_days', String(conversationHistorySettings.recentWindowDays)); diff --git a/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js b/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js index 151104344..d05dc6572 100644 --- a/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js +++ b/src/frontend/web/von_interface/static/js/components/conversationCatalogue.js @@ -22,6 +22,8 @@ const sourceLimit = 100; let filterText = ''; let unreadOnly = false; let allContexts = false; +let showImported = false; +export function importedConversationsVisible() { return showImported; } let onChange = () => {}; let chatReadObserver = null; let chatReadPending = false; @@ -55,6 +57,7 @@ export function filterCatalogueRows(input) { if (filterText) searchRows.forEach(row => combined.set(row.session_id, { ...row, ...combined.get(row.session_id), match: row.match })); const matchedIds = new Set(searchRows.map(row => row.session_id)); return rankCatalogueSearchRows([...combined.values()].filter(row => (!unreadOnly || row.shared_unread_count > 0) + && (showImported || row.origin_kind !== 'external_conversation_import') && (!filterText || metadataMatch(row) || matchedIds.has(row.session_id)))); } @@ -70,6 +73,7 @@ export async function searchCatalogueContent({ append = false } = {}) { onChange(); try { const params = new URLSearchParams({ q: query, match_mode: 'hybrid', sort: 'relevance', page_size: '100' }); + params.set('include_imported', String(showImported)); if (cursor) params.set('cursor', cursor); const data = await getJson(`/von/api/session/conversation_search?${params}`); if (expected !== searchGeneration || contextGeneration !== generation || org !== getSessionScopedOrgId()) return; @@ -102,7 +106,7 @@ export function showChatConversation() { workspace?.classList.remove('show-message-exchange'); } -export function resetConversationCatalogue() { +export function resetConversationCatalogue({ preserveSearch = false } = {}) { generation += 1; searchGeneration += 1; clearTimeout(searchTimer); @@ -110,16 +114,16 @@ export function resetConversationCatalogue() { searchCursor = null; searchStatus = ''; searchPending = false; - filterText = ''; + if (!preserveSearch) filterText = ''; const searchInput = document.querySelector('.catalogue-filters input[type="search"]'); - if (searchInput) searchInput.value = ''; + if (searchInput && !preserveSearch) searchInput.value = ''; rows = []; catalogueRows = []; nextCursor = null; - resetMessagePanelContext(); + if (!preserveSearch) resetMessagePanelContext(); profiles.clear(); refreshPromise = null; - showChatConversation(); + if (!preserveSearch) showChatConversation(); } export async function refreshMessageCatalogue() { @@ -128,7 +132,7 @@ export async function refreshMessageCatalogue() { const org = getSessionScopedOrgId(); refreshPromise = (async () => { try { - const data = await getJson(`/api/messages/conversation-catalogue?limit=${sourceLimit}&all_contexts=${allContexts}`); + const data = await getJson(`/api/messages/conversation-catalogue?limit=${sourceLimit}&all_contexts=${allContexts}&include_imported=${showImported}`); if (expected !== generation || org !== getSessionScopedOrgId()) return; const incomingRows = data.conversations || []; more = data.has_more === true; @@ -264,7 +268,7 @@ export function mountCatalogueControls(container) { const expected = generation; moreButton.disabled = true; try { - const page = await getJson(`/api/messages/conversation-catalogue?limit=${sourceLimit}&cursor=${encodeURIComponent(nextCursor)}&all_contexts=${allContexts}`); + const page = await getJson(`/api/messages/conversation-catalogue?limit=${sourceLimit}&cursor=${encodeURIComponent(nextCursor)}&all_contexts=${allContexts}&include_imported=${showImported}`); if (expected !== generation) return; catalogueRows = [...new Map([...catalogueRows, ...(page.conversations || [])].map(row => [row.session_id, row])).values()]; rows = catalogueRows.filter(row => row.source_kind === 'message_exchange'); @@ -314,7 +318,19 @@ export function initialiseConversationCatalogue({ render, acceptChats, currentCh unread.onclick = () => { unreadOnly = !unreadOnly; unread.setAttribute('aria-pressed', String(unreadOnly)); render(); }; const contexts = document.createElement('button'); contexts.type = 'button'; contexts.textContent = 'All organisations'; contexts.title = 'Include your message exchanges from other organisations'; contexts.setAttribute('aria-pressed', 'false'); contexts.onclick = () => { allContexts = !allContexts; contexts.setAttribute('aria-pressed', String(allContexts)); resetConversationCatalogue(); void refreshMessageCatalogue(); void searchCatalogueContent(); }; - controls.append(filter, unread, contexts); + const imported = document.createElement('button'); imported.type = 'button'; imported.textContent = 'Show imported'; + showImported = false; + imported.title = 'Include imported conversations in this list and its search results'; + imported.setAttribute('aria-pressed', 'false'); + imported.onclick = () => { + showImported = !showImported; + imported.setAttribute('aria-pressed', String(showImported)); + resetConversationCatalogue({ preserveSearch: true }); + render(); + void refreshMessageCatalogue(); + void searchCatalogueContent(); + }; + controls.append(filter, unread, contexts, imported); document.querySelector('.conversation-tray-header')?.after(controls); const refresh = async () => { if (document.visibilityState === 'hidden') return; diff --git a/tests/backend/test_conversation_catalogue.py b/tests/backend/test_conversation_catalogue.py index 3cd24dd88..1006f54bc 100644 --- a/tests/backend/test_conversation_catalogue.py +++ b/tests/backend/test_conversation_catalogue.py @@ -172,3 +172,21 @@ def test_read_baseline_is_quiet_then_incoming_activity_is_per_viewer_and_monoton def test_shared_receipts_are_not_reset_by_normal_conversation_projection(data): rows = [{"session_id": "shared", "shared_with_me": True, "shared_unread_count": 3}] assert reads.project_unread("alice", rows)[0]["shared_unread_count"] == 3 + + +def test_imported_conversations_hidden_before_pagination_and_explicitly_enabled(data, monkeypatch): + stamp = datetime(2026, 9, 16, tzinfo=UTC) + for i in range(120): + data.history.insert_one({"user_id": "#V#alice", "namespace": "alice@lab", "session_id": f"imported-{i:03}", "origin_kind": "external_conversation_import", "last_contribution_at": stamp, "created_at": stamp}) + for i in range(3): + data.history.insert_one({"user_id": "#V#alice", "namespace": "alice@lab", "session_id": f"native-{i}", "last_contribution_at": stamp - timedelta(days=1), "created_at": stamp}) + monkeypatch.setattr(catalogue.messages, "list_exchanges", lambda *a, **k: {"conversations": [], "has_more": False}) + app = Flask(__name__); app.secret_key = "import-filter-test" + with app.app_context(): + default = catalogue.list_catalogue("#V#alice", namespace="alice@lab", limit=2) + assert [r["session_id"] for r in default["conversations"]] == ["native-0", "native-1"] + enabled = catalogue.list_catalogue("#V#alice", namespace="alice@lab", limit=2, include_imported=True) + assert all(r["origin_kind"] == "external_conversation_import" for r in enabled["conversations"]) + with pytest.raises(ValueError, match="another context"): + catalogue.list_catalogue("#V#alice", namespace="alice@lab", limit=2, include_imported=True, cursor=default["next_cursor"]) + assert data.history.count_documents({}) == 123 diff --git a/tests/backend/test_history_sessions_endpoint.py b/tests/backend/test_history_sessions_endpoint.py index fb025ec3f..cb4325eff 100644 --- a/tests/backend/test_history_sessions_endpoint.py +++ b/tests/backend/test_history_sessions_endpoint.py @@ -18,6 +18,12 @@ def app_client(): yield app, client +@pytest.fixture(autouse=True) +def isolated_read_state(monkeypatch): + # This endpoint suite tests listing, not the independent unread store. + monkeypatch.setattr("src.backend.services.conversation_read_service.project_unread", lambda actor, rows: rows) + + def _session_result(sessions, **overrides): payload = { "sessions": sessions, @@ -133,6 +139,7 @@ def _fake_older_count(*args, **kwargs): "human-1", "agent-new", ] + assert called["include_imported"] is False assert called["agent_visibility"] == "exclude" assert called["keep_newest_agent_created"] == "true" assert payload["agent_visibility_applied"] is True @@ -562,3 +569,22 @@ def test_history_sessions_returns_effective_active_session_id_when_authorised( payload = response.get_json() assert payload["authenticated"] is True assert payload["active_session_id"] == "sess-1" + + +@pytest.mark.parametrize("include_imported", [False, True]) +def test_history_sessions_import_display_is_explicit(monkeypatch, app_client, include_imported): + _, client = app_client + monkeypatch.setattr("src.backend.security.access_control.get_effective_user_concept_id", lambda: "#V#u") + monkeypatch.setattr(von_routes, "get_effective_context", lambda *a, **k: {"namespace": "#V#u", "chat_session_id": None}) + observed = {} + def summaries(*a, **kwargs): + observed.update(kwargs) + return _session_result([{"session_id": "native"}, {"session_id": "imported", "origin_kind": "external_conversation_import"}]) + monkeypatch.setattr(von_routes.chat_history_service, "get_chat_history_session_summaries_result", summaries) + monkeypatch.setattr("src.backend.services.shared_conversation_service.list_accepted_invites_for_user", lambda **k: []) + monkeypatch.setattr("src.backend.services.shared_conversation_service.list_outgoing_accepted_invites_for_user", lambda **k: []) + monkeypatch.setattr(von_routes.conversation_management_service, "apply_conversation_preferences", lambda **k: k["conversations"]) + response = client.get("/von/history/sessions?include_imported=" + str(include_imported).lower()) + assert response.status_code == 200 + assert observed["include_imported"] is include_imported + assert [r["session_id"] for r in response.json["sessions"]] == (["native", "imported"] if include_imported else ["native"]) diff --git a/tests/frontend/unifiedConversationCatalogue.test.js b/tests/frontend/unifiedConversationCatalogue.test.js index 011bdd2b5..5b890eb59 100644 --- a/tests/frontend/unifiedConversationCatalogue.test.js +++ b/tests/frontend/unifiedConversationCatalogue.test.js @@ -131,3 +131,26 @@ describe('catalogue content search', () => { expect(controls.textContent).toContain('Retry search'); }); }); + + +test('imports require explicit display and the toggle reaches list and content search', async () => { + jest.useFakeTimers(); + const input = await mountSearch(); + const native = { session_id: 'native', session_name: 'Research' }; + const imported = { session_id: 'imported', session_name: 'Research', origin_kind: 'external_conversation_import' }; + const button = [...document.querySelectorAll('button')].find(b => b.textContent === 'Show imported'); + expect(button.getAttribute('aria-pressed')).toBe('false'); + expect(catalogue.filterCatalogueRows([imported, native])).toEqual([native]); + enterQuery(input, 'Research'); + api.getJson.mockResolvedValue({ success: true, conversations: [native, imported], results: [native, imported] }); + button.click(); + await Promise.resolve(); await Promise.resolve(); + expect(button.getAttribute('aria-pressed')).toBe('true'); + expect(input.value).toBe('Research'); + expect(catalogue.filterCatalogueRows([imported, native])).toHaveLength(2); + expect(api.getJson.mock.calls.some(([url]) => url.includes('conversation-catalogue') && url.includes('include_imported=true'))).toBe(true); + expect(api.getJson.mock.calls.some(([url]) => url.includes('conversation_search') && url.includes('include_imported=true'))).toBe(true); + button.click(); + expect(catalogue.filterCatalogueRows([imported, native])).toEqual([native]); + jest.clearAllTimers(); jest.useRealTimers(); +});