Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/engineering/external_conversation_import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/backend/server/routes/message_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions src/backend/server/routes/von_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions src/backend/services/chat_history_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]] = [
{
Expand Down Expand Up @@ -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]:
Expand All @@ -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},
Expand Down Expand Up @@ -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]]:
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
9 changes: 6 additions & 3 deletions src/backend/services/conversation_catalogue_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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.")
Expand All @@ -134,6 +136,7 @@ def list_catalogue(
"namespace": namespace,
"organisation": organisation,
"all_contexts": all_contexts,
"include_imported": include_imported,
}
before = None
if cursor:
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/backend/services/conversation_management_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/backend/services/conversation_search_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))));
}

Expand All @@ -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;
Expand Down Expand Up @@ -102,24 +106,24 @@ export function showChatConversation() {
workspace?.classList.remove('show-message-exchange');
}

export function resetConversationCatalogue() {
export function resetConversationCatalogue({ preserveSearch = false } = {}) {
generation += 1;
searchGeneration += 1;
clearTimeout(searchTimer);
searchRows = [];
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() {
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading