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
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ let _replySendFailureState = null;
let _newMessageSendFailureState = null;
let _replySendPending = false;
let _newMessageSendPending = false;
let _replyContextPrefix = null;
let _replyDeliveryAttempt = null;
let _newMessageDeliveryAttempt = null;
let _deliveryKeySequence = 0;
Expand All @@ -67,6 +68,7 @@ function exchangeScope(row = _exchange) {
}

export function resetMessagePanelContext() {
clearReplyContext();
const input = _messagesContainer?.querySelector('#messageInput');
if (_exchange && input) _exchangeDrafts.set(exchangeScope(), input.value);
_exchangeGeneration += 1;
Expand Down Expand Up @@ -723,6 +725,7 @@ function renderThreadList() {
* Select a conversation to view.
*/
async function selectConversation(userId) {
clearReplyContext();
const renderedSelection = _messagesContainer?.querySelector(
'.message-thread-item.selected',
);
Expand Down Expand Up @@ -1098,6 +1101,55 @@ async function loadMessageRecommendationReview(messageId, { forceReload = false
}
}

// Keep the quote in the ordinary draft/body so existing persistence and retry
// retain it, including when the original message is later unavailable.
function clearReplyContext({ removeQuote = false } = {}) {
const input = _messagesContainer?.querySelector('#messageInput');
if (removeQuote && input && _replyContextPrefix) {
if (!input.value.startsWith(_replyContextPrefix)) {
showToast('The quote was edited. Remove it directly from your draft.', 'info');
return false;
}
input.value = input.value.slice(_replyContextPrefix.length);
resizeCompactDraft(input);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
_replyContextPrefix = null;
_messagesContainer?.querySelector('.message-reply-context-controls')?.remove();
return true;
}

function selectReplyContext(messageId) {
if (_replySendPending || _exchange?.other_participant_ids?.length > 1) return;
const input = _messagesContainer?.querySelector('#messageInput');
const message = _currentMessages.find(item => item.concept_id === messageId);
if (!input || !message) {
showToast('This message is no longer available. Your draft has been kept.', 'warning');
return;
}
if (!clearReplyContext({ removeQuote: true })) return;
const sender = message.relationships?.['#V#has_sender']?.[0] || '';
const content = message.concept_data?.content_fallback || '[Message text unavailable]';
_replyContextPrefix = `Replying to ${formatUserName(sender)} (${messageId}):\n${content.split(/\r?\n/u).map(line => `> ${line}`).join('\n')}\n\n`;
input.value = _replyContextPrefix + input.value;
const controls = document.createElement('div');
controls.className = 'message-reply-context-controls';
const note = document.createElement('span');
note.textContent = 'Quoted message included in your reply. ';
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.textContent = 'Cancel reply context';
cancel.onclick = () => {
if (!_replySendPending) clearReplyContext({ removeQuote: true });
};
controls.append(note, cancel);
input.closest('#messageComposeArea').prepend(controls);
resizeCompactDraft(input);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}

/**
* Render messages in the conversation view.
*/
Expand Down Expand Up @@ -1160,7 +1212,7 @@ async function renderMessages() {
<span class="message-time">${timestamp ? formatTime(timestamp) : ''}</span>
<button type="button" class="message-copy-markdown" data-message-id="${escapeHtml(msg.concept_id || '')}" aria-label="Copy message as Markdown" title="Copy message as Markdown">${renderMessagePanelIcon('copy')}<span class="message-copy-label">Copy</span></button>
<span class="message-copy-status" role="status"></span>
${msg.concept_id ? `<button type="button" class="message-discuss-btn"
${msg.concept_id ? `<button type="button" class="message-reply-context-btn" data-message-id="${escapeHtml(msg.concept_id)}" title="Quote this message in the current reply">Reply with context</button><button type="button" class="message-discuss-btn"
data-concept-id="${escapeHtml(msg.concept_id)}"
title="Discuss this message with Von">Discuss with Von</button>` : ''}
</div>
Expand Down Expand Up @@ -1209,6 +1261,10 @@ async function renderMessages() {
status.textContent = copied ? 'Copied' : 'Copy failed';
};
});
contentEl.querySelectorAll('.message-reply-context-btn').forEach(button => {
button.disabled = Boolean(_exchange?.other_participant_ids?.length > 1);
button.onclick = () => selectReplyContext(button.dataset.messageId);
});
contentEl.querySelectorAll('.message-discuss-btn').forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
Expand Down Expand Up @@ -2272,6 +2328,7 @@ async function handleSendReply() {
_currentConversationUserId === submittedConversationUserId
&& activeReplyInput?.value.trim() === content
) {
clearReplyContext();
activeReplyInput.value = '';
resizeCompactDraft(activeReplyInput);
}
Expand Down
5 changes: 4 additions & 1 deletion src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2047,6 +2047,7 @@ body.von-signed-out {

.task-discuss-btn,
.message-discuss-btn,
.message-reply-context-btn,
.concept-discuss-button {
border: 1px solid #86efac;
border-radius: 5px;
Expand All @@ -2061,7 +2062,8 @@ body.von-signed-out {
font-size: 0.75rem;
}

.message-discuss-btn {
.message-discuss-btn,
.message-reply-context-btn {
margin-left: 7px;
padding: 1px 5px;
font-size: 0.68rem;
Expand Down Expand Up @@ -2157,6 +2159,7 @@ body.von-signed-out {

.task-discuss-btn:hover,
.message-discuss-btn:hover,
.message-reply-context-btn:hover,
.concept-discuss-button:hover {
background: #dcfce7;
border-color: #4ade80;
Expand Down
73 changes: 73 additions & 0 deletions tests/browser/messageReplyContext.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Fixture-backed browser check of the production Messages module and CSS.
// No live accounts, backend writes or model calls.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const { chromium, expect } = require('@playwright/test');

const root = path.resolve(__dirname, '../../src/frontend/web/von_interface');
const markup = '<div id=messagesContainer></div>';
const evidence = process.argv[2];
if (evidence) fs.mkdirSync(evidence, { recursive: true });
const server = http.createServer((req, res) => {
if (req.url === '/') {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/styles.css">${markup}`);
return;
}
const file = path.resolve(root, `.${req.url}`);
if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) {
res.writeHead(404).end();
return;
}
res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript');
res.end(fs.readFileSync(file));
});

(async () => {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const browser = await chromium.launch({ headless: true });
try {
for (const width of [1280, 390]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
let sent;
let messages = [{ concept_id: '#V#source', concept_data: { content_fallback: 'Please review the experiment results.' }, relationships: { '#V#has_sender': ['#V#bob'] } }];
await page.route('**/api/**', async route => {
const url = route.request().url();
let data = {};
if (url.endsWith('/messages/') && route.request().method() === 'POST') {
sent = route.request().postDataJSON();
messages = [{ concept_id: '#V#reply', concept_data: { content_fallback: sent.content }, relationships: { '#V#has_sender': ['#V#alice'] } }];
data = { success: true };
} else if (url.includes('/messages/exchange')) data = { messages, current_user_id: '#V#alice' };
else if (url.includes('/messages/threads')) data = { threads: [] };
await route.fulfill({ json: data });
});
await page.goto(`http://127.0.0.1:${server.address().port}`);
await page.evaluate(async () => {
const panel = await import('/static/js/components/messagePanel.js');
await panel.openMessageExchange({ session_id: 'existing', viewer_id: '#V#alice', session_name: 'Bob',
participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'], organisation_concept_id: '#V#lab' });
});
const input = page.locator('#messageInput');
await input.fill('I will review them today.');
await page.getByRole('button', { name: 'Reply with context', exact: true }).click();
await expect(input).toHaveValue(/Please review the experiment results/);
await expect(page.getByText('Quoted message included in your reply.')).toBeVisible();
await page.getByRole('button', { name: 'Cancel reply context' }).click();
await expect(input).toHaveValue('I will review them today.');
await page.getByRole('button', { name: 'Reply with context', exact: true }).click();
if (evidence) await page.screenshot({ path: path.join(evidence, `reply-context-${width}.png`) });
await page.locator('#sendMessageBtn').click();
await expect(page.locator('.message-content')).toContainText('I will review them today.');
await expect(page.locator('.message-content')).toContainText('Please review the experiment results.');
assert.deepEqual(sent.recipient_ids, ['#V#bob']);
assert.equal(sent.organisation_concept_id, '#V#lab');
assert.match(sent.content, /#V#source/);
await page.close();
}
console.log('PASS: Messages selection, cancellation, current exchange send and visible saved quote at 1280 and 390px.');
} finally { await browser.close(); server.close(); }
})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });
182 changes: 182 additions & 0 deletions tests/frontend/messagePanelReplyContext.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/** @jest-environment jsdom */

jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({
getJson: jest.fn(),
postJson: jest.fn(),
postJsonDetailed: jest.fn(),
}));

jest.mock('../../src/frontend/web/von_interface/static/js/utils/toast.js', () => ({
showToast: jest.fn(),
}));

const modulePath = '../../src/frontend/web/von_interface/static/js/components/messagePanel.js';

function renderFixture() {
document.body.innerHTML = `
<div id="messagesContainer"></div>
<span id="unreadMessageBadge" class="hidden"></span>
`;
}

function flushUi() {
return new Promise((resolve) => setTimeout(resolve, 0));
}

function setMessageSessionScope({ actorId, organisationId }) {
sessionStorage.setItem('von_current_user', JSON.stringify({ concept_id: actorId }));
sessionStorage.setItem('von_current_org', JSON.stringify({ concept_id: organisationId }));
}

function mockReplyReads(getJson, {
actorId = '#V#user_alice',
recipientIds = ['#V#user_bob'],
threadIdByRecipient = {},
} = {}) {
getJson.mockImplementation(async (url) => {
if (url === '/api/messages/threads?limit=20') {
return {
threads: recipientIds.map((recipientId) => ({
_id: [recipientId],
last_message: {
concept_data: { content_fallback: 'Earlier message' },
},
message_count: 1,
})),
};
}
if (url === '/api/messages/unread/count') {
return { unread_count: 0 };
}
const conversationMatch = url.match(
/^\/api\/messages\/conversation\/(.+)\?limit=50$/u,
);
if (conversationMatch) {
const recipientId = decodeURIComponent(conversationMatch[1]);
const threadId = threadIdByRecipient[recipientId] || '';
return {
messages: [{
concept_id: `#V#message_for_${recipientId.replace(/^#V#/u, '')}`,
relationships: {
'#V#has_sender': [actorId],
'#V#has_recipient': [recipientId],
...(threadId ? { '#V#is_part_of_thread': [threadId] } : {}),
},
concept_data: {
content_fallback: 'Earlier message',
read_by: [],
},
created_at: '2026-08-28T00:00:00Z',
}],
current_user_id: actorId,
};
}
throw new Error(`Unexpected getJson call: ${url}`);
});
}

describe('reply with selected message context', () => {
let api;
let panel;
const input = () => document.querySelector('#messageInput');
const select = () => document.querySelector('.message-reply-context-btn').click();
beforeEach(async () => {
localStorage.clear(); sessionStorage.clear();
renderFixture(); window.scrollTo = jest.fn();
api = require('../../src/frontend/web/von_interface/static/js/apiService.js');
panel = require(modulePath);
setMessageSessionScope({ actorId: '#V#user_alice', organisationId: '#V#lab' });
mockReplyReads(api.getJson, { threadIdByRecipient: { '#V#user_bob': '#V#thread_1' } });
api.postJson.mockResolvedValue({});
api.postJsonDetailed.mockResolvedValue({ data: { success: true }, status: 201 });
global.fetch = jest.fn(async () => ({ ok: true, json: async () => ({}), text: async () => '{}' }));
panel.initializeMessagePanel();
await panel.showMessagesTab(); await flushUi();
});
afterEach(() => { jest.resetModules(); jest.clearAllMocks(); delete global.fetch; });

test('selects visibly in the current composer and cancels without losing the draft', () => {
input().value = 'My answer';
const discuss = jest.fn(); document.addEventListener('von:discussConcept', discuss);
select();
expect(input().value).toContain('> Earlier message');
expect(input().value).toContain('#V#message_for_user_bob');
expect(document.activeElement).toBe(input());
expect(document.querySelector('.message-thread-item.selected').dataset.userId).toBe('#V#user_bob');
expect(discuss).not.toHaveBeenCalled();
document.querySelector('.message-reply-context-controls button').click();
expect(input().value).toBe('My answer');
document.removeEventListener('von:discussConcept', discuss);
});

test('reselecting replaces the quote and edited quotes are never silently removed', () => {
input().value = 'My answer'; select(); select();
expect(input().value.match(/Replying to/g)).toHaveLength(1);
input().value = input().value.replace('Earlier', 'Edited');
const edited = input().value;
document.querySelector('.message-reply-context-controls button').click();
expect(input().value).toBe(edited);
expect(require('../../src/frontend/web/von_interface/static/js/utils/toast.js').showToast)
.toHaveBeenCalledWith(expect.stringContaining('edited'), 'info');
});

test('sends and renders the quote through the existing thread and keeps it on failure', async () => {
input().value = 'My answer'; select();
const draft = input().value;
api.postJsonDetailed.mockRejectedValueOnce(new Error('Offline'));
document.querySelector('#sendMessageBtn').click(); await flushUi();
expect(input().value).toBe(draft);
api.getJson.mockImplementation(async url => {
if (url.includes('/conversation/')) return { current_user_id: '#V#user_alice', messages: [{
concept_id: '#V#sent', concept_data: { content_fallback: draft },
relationships: { '#V#has_sender': ['#V#user_alice'] }
}] };
return { threads: [], unread_count: 0 };
});
document.querySelector('#sendMessageBtn').click(); await flushUi();
expect(api.postJsonDetailed).toHaveBeenLastCalledWith('/api/messages/', expect.objectContaining({
content: draft, recipient_ids: ['#V#user_bob'], organisation_concept_id: '#V#lab'
}));
expect(document.querySelector('.message-content').textContent).toContain('Earlier message');
expect(document.querySelector('.message-content').textContent).toContain('My answer');
expect(input().value).toBe('');
});

test('changes source, retains a deleted source snapshot, and stays in the selected exchange', async () => {
let messages = [
{ concept_id: '#V#first', concept_data: { content_fallback: 'First source' } },
{ concept_id: '#V#second', concept_data: {} }
];
api.postJson.mockImplementation(async () => ({ messages, current_user_id: '#V#user_alice' }));
const row = { session_id: 'existing-exchange', viewer_id: '#V#user_alice',
session_name: 'Bob', participant_ids: ['#V#user_alice', '#V#user_bob'],
other_participant_ids: ['#V#user_bob'], organisation_concept_id: '#V#original_lab' };
await panel.openMessageExchange(row);
input().value = 'Answer'; select();
document.querySelectorAll('.message-reply-context-btn')[1].click();
expect(input().value).not.toContain('First source');
expect(input().value).toContain('#V#second');
expect(input().value).toContain('[Message text unavailable]');
const draft = input().value;
messages = [];
document.body.insertAdjacentHTML('beforeend', '<div id="conversationWorkspace" class="show-message-exchange"></div>');
await panel.refreshOpenMessageExchange();
expect(input().value).toBe(draft);
document.querySelector('#sendMessageBtn').click(); await flushUi();
expect(api.postJsonDetailed).toHaveBeenLastCalledWith('/api/messages/', expect.objectContaining({
content: draft, recipient_ids: ['#V#user_bob'], organisation_concept_id: '#V#original_lab'
}));
expect(api.postJson).toHaveBeenLastCalledWith('/api/messages/exchange', expect.objectContaining({
participant_ids: row.participant_ids, organisation_concept_id: '#V#original_lab'
}));
});

test('unavailable selection keeps the draft', () => {
input().value = 'Keep this';
document.querySelector('.message-reply-context-btn').dataset.messageId = '#V#deleted';
select();
expect(input().value).toBe('Keep this');
expect(require('../../src/frontend/web/von_interface/static/js/utils/toast.js').showToast)
.toHaveBeenCalledWith(expect.stringContaining('no longer available'), 'warning');
});
});
Loading