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
5 changes: 4 additions & 1 deletion src/backend/integrations/internal_mcp/catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -6875,6 +6875,7 @@ def _message_list_direct(**kwargs):
thread_id=kwargs.get("thread_id"),
limit=limit,
skip=offset,
other_user_id=kwargs.get("other_user_concept_id"),
)
projected = [project_direct_message(message) for message in messages]
return {
Expand Down Expand Up @@ -13094,6 +13095,7 @@ def _message_list_direct_input_schema() -> Schema:
"include_sent": (bool, type(None)),
"include_received": (bool, type(None)),
"thread_id": (str, type(None)),
"other_user_concept_id": (str, type(None)),
"limit": (int, type(None)),
"offset": (int, type(None)),
"acting_user_concept_id": (str, type(None)),
Expand All @@ -13102,7 +13104,8 @@ def _message_list_direct_input_schema() -> Schema:
},
allow_unknown=False,
description=(
"List the authenticated actor's sent and received internal direct messages."
"List the authenticated actor's sent and received internal direct messages, "
"optionally narrowed to a conversation with other_user_concept_id."
),
)

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 @@ -875,6 +875,7 @@ def list_threads() -> ResponseReturnValue:
{
"threads": threads,
"count": len(threads),
"current_user_id": user_id,
}
),
200,
Expand Down
29 changes: 23 additions & 6 deletions src/backend/services/message_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,11 +337,7 @@ def relationship_values(predicate: str) -> List[str]:
"content": concept_data.get("content_fallback"),
"sent_at": concept_data.get("sent_at"),
"status": concept_data.get("message_status"),
"read_by": [
item
for item in raw_read_by
if isinstance(item, str)
],
"read_by": [item for item in raw_read_by if isinstance(item, str)],
"thread_id": (
relationship_values(PREDICATE_THREAD)[0]
if relationship_values(PREDICATE_THREAD)
Expand Down Expand Up @@ -429,7 +425,9 @@ def create_message(
relationships[PREDICATE_REPLY_TO] = [reply_to_id.strip()]

metadata_payload = metadata if isinstance(metadata, dict) else {}
metadata_payload = {str(k): v for k, v in metadata_payload.items() if isinstance(k, str)}
metadata_payload = {
str(k): v for k, v in metadata_payload.items() if isinstance(k, str)
}
attribution = metadata_payload.get("attribution")
if not isinstance(attribution, str) or not attribution.strip():
attribution = f"Sent by Von on behalf of {sender_id}"
Expand Down Expand Up @@ -745,6 +743,7 @@ def get_messages_for_user(
thread_id: Optional[str] = None,
limit: int = 50,
skip: int = 0,
other_user_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Get messages for a user (sent and/or received).

Expand Down Expand Up @@ -785,6 +784,24 @@ def get_messages_for_user(
if thread_id:
base_filter[f"relationships.{PREDICATE_THREAD}"] = thread_id.strip()

if other_user_id:
# Further narrow the actor's existing mailbox; never replace its scope.
counterpart = other_user_id.strip()
base_filter["$and"] = [
{
"$or": [
{
f"relationships.{PREDICATE_SENDER}": user_id,
f"relationships.{PREDICATE_RECIPIENT}": counterpart,
},
{
f"relationships.{PREDICATE_SENDER}": counterpart,
f"relationships.{PREDICATE_RECIPIENT}": user_id,
},
]
}
]

query = apply_concept_query_filter(base_filter)

cursor = coll.find(query).sort("created_at", -1).skip(skip).limit(limit)
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -28181,7 +28181,7 @@ function populateChatSessionMenu(items) {
return menu;
}

function openChatSessionMenu(x, y, items, options = {}) {
export function openChatSessionMenu(x, y, items, options = {}) {
if (!Array.isArray(items) || items.length === 0) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { getSessionScopedOrgId } from '../utils/sessionScopedStorage.js';
import { hydrateConceptCartouchesInRoot } from '../utils/selectConceptByIdHandler.js';
import { cartouchifyElementText } from '../utils/textDecorator.js';
import { showToast } from '../utils/toast.js';
import { buildMessageStreamReference } from '../utils/messageStreamReference.js';
import { copyTextWithClipboardFallback } from '../utils/copyJsonButtonState.js';
import {
clearRecommendationReviewResults,
renderRecommendationReviewPayload,
Expand Down Expand Up @@ -136,7 +138,8 @@ function renderMessagesTabContent() {
</div>
<div class="messages-main">
<div id="messageViewHeader" class="message-view-header hidden">
<span id="conversationTitle" class="conversation-title">Select a conversation</span>
<span id="conversationTitle" class="conversation-title" tabindex="0">Select a conversation</span>
<button id="messageTaskPanelBtn" class="chat-export-btn" type="button" title="Open tasks panel" aria-label="Tasks">📋</button>
<button id="refreshMessagesBtn" class="message-refresh-btn" type="button" title="Refresh" aria-label="Refresh messages">
${renderMessagePanelIcon('refresh')}
</button>
Expand Down Expand Up @@ -305,6 +308,7 @@ async function loadMessageThreads() {
try {
const response = await getJson('/api/messages/threads?limit=20');
_threads = response.threads || [];
_currentUserId = response.current_user_id || null;
renderThreadList();
autoOpenUserId = resolveStoredReplyAttemptAutoOpenUserId()
|| resolveAutoOpenThreadUserId();
Expand Down Expand Up @@ -392,7 +396,7 @@ function renderThreadList() {
const userId = getThreadUserId(thread);

html += `
<div class="message-thread-item" data-user-id="${escapeHtml(userId)}"
<div class="message-thread-item" tabindex="0" data-user-id="${escapeHtml(userId)}"
title="Conversation with ${escapeHtml(displayName)}">
<div class="thread-avatar">${getInitials(displayName)}</div>
<div class="thread-info">
Expand All @@ -411,7 +415,9 @@ function renderThreadList() {

// Attach click handlers
threadListEl.querySelectorAll('.message-thread-item').forEach(item => {
item.addEventListener('click', () => {
bindMessageStreamMenu(item, () => item.dataset.userId);
item.addEventListener('click', (event) => {
if (event.ctrlKey) return;
const userId = item.dataset.userId;
if (userId) {
selectConversation(userId);
Expand Down Expand Up @@ -458,11 +464,54 @@ async function selectConversation(userId) {
if (header) header.classList.remove('hidden');
if (compose) compose.classList.remove('hidden');
if (title) title.textContent = `Conversation with ${formatUserName(userId)}`;
if (title && !title.dataset.menuBound) {
bindMessageStreamMenu(title, () => _currentConversationUserId);
title.dataset.menuBound = 'true';
}
const taskButton = _messagesContainer?.querySelector('#messageTaskPanelBtn');
if (taskButton) taskButton.onclick = () => void openMessageTasks();

// Load the conversation
await loadConversation(userId);
}

async function openMessageTasks() {
const { showTaskPanel } = await import('./taskPanel.js');
const taskIds = [...(_messagesContainer?.querySelectorAll('[data-task-concept="true"]') || [])]
.map(element => element.dataset.fullConceptId);
await showTaskPanel({ taskIds, sessionId: null });
}

function bindMessageStreamMenu(element, getOtherUserId) {
const open = async event => {
event.preventDefault();
event.stopPropagation();
const otherUserId = getOtherUserId();
const userId = _currentUserId;
if (!otherUserId || !userId) return;
const thread = _threads.find(row => getThreadUserId(row) === otherUserId);
const messages = otherUserId === _currentConversationUserId ? _currentMessages : [thread?.last_message].filter(Boolean);
const payload = buildMessageStreamReference({ currentUserId: userId, otherUserId, messages, displayName: `Conversation with ${formatUserName(otherUserId)}` });
const { openChatSessionMenu } = await import('../chatTab.js');
if (!element.isConnected || _currentUserId !== userId) return;
const rect = element.getBoundingClientRect();
openChatSessionMenu(event.clientX || rect.left, event.clientY || rect.bottom, [{
label: 'Copy conversation reference',
onClick: async () => {
if (_currentUserId !== userId) return;
const copied = await copyTextWithClipboardFallback(JSON.stringify(payload, null, 2));
showToast(copied ? 'Copied message conversation reference.' : 'Failed to copy conversation reference.', copied ? 'success' : 'error');
}
}], { returnFocus: element, focusFirst: true });
};
element.addEventListener('contextmenu', open);
element.addEventListener('click', event => { if (event.ctrlKey) void open(event); });
element.addEventListener('keydown', event => {
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) void open(event);
else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); void selectConversation(getOtherUserId()); }
});
}

/**
* Load messages for a conversation with a specific user.
*/
Expand Down
60 changes: 55 additions & 5 deletions src/frontend/web/von_interface/static/js/components/taskPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ let _filterTaskSourceId = 'all';
let _isGlobalTabMode = false; // True when rendering into global tasks tab
let _taskDetailState = {}; // taskId -> detail panel state
let _selectedTaskId = '';
let _panelTaskIds = null;
let _panelLoadGeneration = 0;
let _bulkTaskVisibility = 'exclude';
let _bulkTaskCollectionId = '';
let _taskProjects = [];
Expand Down Expand Up @@ -173,6 +175,8 @@ async function ensureTaskOrganisationOptionsLoaded() {
}

function resetTaskPanelScopedState(activeOrganisationConceptId, { actorChanged = false } = {}) {
_panelTaskIds = null;
_panelLoadGeneration += 1;
_taskQueueActivityGeneration += 1;
stopTaskQueueActivityPolling();
_taskQueueActivityLoadPromise = null;
Expand Down Expand Up @@ -734,13 +738,17 @@ function pruneTaskDetailState() {
* Call this once on page load to set up the panel element.
*/
export function initializeTaskPanel() {
if (_panelEl?.isConnected && _panelEl.dataset.initialized === 'true') return;
_panelEl = document.getElementById('taskPanel');
_taskListEl = document.getElementById('taskList');

if (!_panelEl || !_taskListEl) {
console.warn('[taskPanel] Panel elements not found in DOM');
return;
}
// One existing popup, available from Messages as well as Conversations.
document.body.appendChild(_panelEl);
_panelEl.dataset.initialized = 'true';
loadTaskGroupSelectionFromStorage();
ensureTaskPanelOrganisationSwitchListener();
ensureTaskPanelAuthStatusListener();
Expand Down Expand Up @@ -794,7 +802,7 @@ export function initializeTaskPanel() {
// Set up refresh button
const refreshBtn = document.getElementById('refreshTasksBtn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => loadTasks(_currentSessionId));
refreshBtn.addEventListener('click', () => refreshTasks());
}

console.log('[taskPanel] Initialized');
Expand All @@ -804,27 +812,58 @@ export function initializeTaskPanel() {
* Show the task panel.
*/
export function showTaskPanel(options = {}) {
initializeTaskPanel();
applyPanelOpenOptions(options);
if (_panelEl) {
loadTaskGroupSelectionFromStorage();
_globalTaskLoadGeneration += 1;
_isGlobalTabMode = false;
syncTaskQueueActivityPolling();
_taskListEl = document.getElementById('taskList') || _taskListEl;
_panelEl.classList.remove('hidden');
_panelEl.setAttribute('aria-hidden', 'false');
_panelEl.querySelectorAll('.task-create-form, .task-filter-row').forEach(element => {
element.classList.toggle('hidden', _panelTaskIds !== null);
});
_isVisible = true;
// Auto-refresh tasks when panel becomes visible
refreshTasks();
return refreshTasks();
}
}

function applyPanelOpenOptions(options = {}) {
if (!options || typeof options !== 'object') return;
_panelTaskIds = Array.isArray(options.taskIds) ? [...new Set(options.taskIds.filter(Boolean))] : null;
_panelLoadGeneration += 1;
if (Object.prototype.hasOwnProperty.call(options, 'sessionId')) {
_currentSessionId = options.sessionId || null;
}
}

export async function openTaskInPanel(taskId) {
await showTaskPanel({ taskIds: [taskId], sessionId: null });
if (!_tasks.some(task => getTaskId(task) === taskId) || _isGlobalTabMode) return;
getTaskDetailState(taskId).expanded = true;
await loadTaskDetails(taskId);
_panelEl?.querySelector('.task-item')?.scrollIntoView?.({ block: 'nearest' });
}

async function loadReferencedPanelTasks() {
const generation = ++_panelLoadGeneration;
const scopeGeneration = _taskQueueActivityGeneration;
_tasks = [];
_taskDetailState = {};
renderTaskList();
try {
const tasks = await Promise.all(_panelTaskIds.map(id => getJson(`/api/tasks/${encodeURIComponent(id)}`)));
if (generation !== _panelLoadGeneration || scopeGeneration !== _taskQueueActivityGeneration || _isGlobalTabMode) return;
_tasks = tasks;
renderTaskList();
} catch (_) {
if (generation === _panelLoadGeneration) showToast('Could not load the referenced tasks.', 'error');
}
}

/**
* Hide the task panel.
*/
Expand Down Expand Up @@ -1224,6 +1263,8 @@ export function getTaskCount() {
*/
export async function loadTasks(sessionId = null) {
if (_isLoading) return;
const panelGeneration = _panelLoadGeneration;
const scopeGeneration = _taskQueueActivityGeneration;

loadTaskGroupSelectionFromStorage();
_isLoading = true;
Expand Down Expand Up @@ -1252,6 +1293,7 @@ export async function loadTasks(sessionId = null) {
}

const response = await getJson(url);
if (panelGeneration !== _panelLoadGeneration || scopeGeneration !== _taskQueueActivityGeneration) return;
_tasks = response.tasks || [];
setBulkTaskVisibilitySummary(response);
pruneTaskDetailState();
Expand All @@ -1273,6 +1315,8 @@ export async function loadTasks(sessionId = null) {
*/
export async function loadMyTasks(statusFilter = null) {
if (_isLoading) return;
const panelGeneration = _panelLoadGeneration;
const scopeGeneration = _taskQueueActivityGeneration;

loadTaskGroupSelectionFromStorage();
_isLoading = true;
Expand All @@ -1281,6 +1325,7 @@ export async function loadMyTasks(statusFilter = null) {
try {
const url = buildMyTasksUrl(statusFilter);
const response = await getJson(url);
if (panelGeneration !== _panelLoadGeneration || scopeGeneration !== _taskQueueActivityGeneration) return;
_tasks = response.tasks || [];
setBulkTaskVisibilitySummary(response);
pruneTaskDetailState();
Expand Down Expand Up @@ -1457,6 +1502,8 @@ function buildTaskSearchText(task) {
}

function getFilteredTasks() {
// Exact references remain reachable regardless of saved list filters.
if (!_isGlobalTabMode && _panelTaskIds !== null) return _tasks;
return _tasks.filter((task) => {
if (!taskMatchesGlobalScope(task)) {
return false;
Expand Down Expand Up @@ -1780,9 +1827,10 @@ function renderTaskList() {

const previousScrollLeft = _taskListEl.scrollLeft;
const previousScrollTop = _taskListEl.scrollTop;
const isBoardView = _viewMode === 'board';
const effectiveViewMode = !_isGlobalTabMode && _panelTaskIds !== null ? 'list' : _viewMode;
const isBoardView = effectiveViewMode === 'board';
const scrollHintEl = _globalTasksContainer?.querySelector('#globalTaskBoardScrollHint');
_taskListEl.dataset.viewMode = _viewMode;
_taskListEl.dataset.viewMode = effectiveViewMode;
_taskListEl.setAttribute('aria-label', isBoardView ? 'Task board' : 'Task list');
if (isBoardView) {
_taskListEl.setAttribute('aria-describedby', 'globalTaskBoardScrollHint');
Expand Down Expand Up @@ -1817,7 +1865,7 @@ function renderTaskList() {
}

let html = '';
if (_viewMode === 'list') {
if (effectiveViewMode === 'list') {
const sortedTasks = [...filteredTasks].sort((left, right) => {
const leftTimestamp = new Date(left.updated_at || left.created_at || 0).getTime();
const rightTimestamp = new Date(right.updated_at || right.created_at || 0).getTime();
Expand Down Expand Up @@ -3997,6 +4045,8 @@ export function getTasks() {
export async function refreshTasks() {
if (_isGlobalTabMode) {
await loadGlobalTasks();
} else if (_panelTaskIds !== null) {
await loadReferencedPanelTasks();
} else if (_currentSessionId) {
await loadTasks(_currentSessionId);
} else {
Expand Down
Loading
Loading