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 @@ -843,7 +843,7 @@ async function loadConversation(userId, { silent = false, before = null, observe
if (!overlap && response.before) _olderCursor = response.before;
nextMessages = [..._currentMessages.filter(m => compare(m, incoming[0]) < 0), ...incoming];
}
const sameContent = JSON.stringify(nextMessages.map(m => [m.concept_id, m.concept_data?.content_fallback])) === JSON.stringify(_currentMessages.map(m => [m.concept_id, m.concept_data?.content_fallback]));
const sameContent = JSON.stringify(nextMessages.map(messageRenderKey)) === JSON.stringify(_currentMessages.map(messageRenderKey));
_currentMessages = nextMessages;
if (silent && !before && sameContent) {
updateUnreadMarkers();
Expand Down Expand Up @@ -1164,12 +1164,24 @@ function selectReplyContext(messageId) {
input.setSelectionRange(input.value.length, input.value.length);
}

/**
* Render messages in the conversation view.
*/
const messageRenderKeys = new WeakMap();

function messageRenderKey(message) {
const { read_by: _readBy, ...data } = message.concept_data || {};
return JSON.stringify([message.concept_id, message.created_at, message.relationships, data,
_currentUserId, _currentConversationUserId, _exchangeGeneration, _exchange?.participant_profiles]);
}

/** Render messages without resetting unchanged contribution state. */
async function renderMessages(onRendered = () => {}) {
const contentEl = _messagesContainer?.querySelector('#messageViewContent');
if (!contentEl) return;
const liveContent = _messagesContainer?.querySelector('#messageViewContent');
if (!liveContent) return;
// Prepare changed contributions off-screen; retain hydrated cartouches and
// local interaction state for unchanged contributions in the live list.
const contentEl = document.createElement('div');
const existingList = liveContent.querySelector('.message-list');
const existing = new Map(Array.from(existingList?.children || [])
.map(bubble => [bubble.dataset.contributionId, bubble]));

if (_currentMessages.length === 0) {
contentEl.innerHTML = `
Expand All @@ -1179,6 +1191,7 @@ async function renderMessages(onRendered = () => {}) {
<p class="message-empty-hint">Send the first message!</p>
</div>
`;
liveContent.replaceChildren(...contentEl.childNodes);
onRendered();
return;
}
Expand Down Expand Up @@ -1237,6 +1250,16 @@ async function renderMessages(onRendered = () => {}) {

html += '</div>';
contentEl.innerHTML = html;
const orderedBubbles = Array.from(contentEl.querySelectorAll('.message-bubble')).map((bubble, index) => {
const key = messageRenderKey(_currentMessages[index]);
const retained = existing.get(bubble.dataset.contributionId);
if (retained && messageRenderKeys.get(retained) === key) {
bubble.remove();
return retained;
}
messageRenderKeys.set(bubble, key);
return bubble;
});
contentEl.querySelectorAll('.message-author[data-participant-id]').forEach(author => {
const id = author.dataset.participantId;
const profile = _exchange?.participant_profiles?.find(profile => profile.concept_id === id) || { display_name: formatUserName(id) };
Expand Down Expand Up @@ -1295,9 +1318,18 @@ async function renderMessages(onRendered = () => {}) {
}));
});
});
const list = existingList || document.createElement('div');
list.className = 'message-list';
const retained = new Set(orderedBubbles);
for (const bubble of Array.from(list.children)) if (!retained.has(bubble)) bubble.remove();
orderedBubbles.forEach((bubble, index) => {
if (list.children[index] !== bubble) list.insertBefore(bubble, list.children[index] || null);
});
if (!existingList) liveContent.replaceChildren(list);
else for (const child of Array.from(liveContent.children)) if (child !== list) child.remove();
// Restore before asynchronous hydration yields a frame with the new DOM.
onRendered();
await hydrateConceptCartouchesInRoot(contentEl);
await hydrateConceptCartouchesInRoot(liveContent);

// The loader restores the viewport before attaching read observers.
}
Expand Down
87 changes: 87 additions & 0 deletions tests/browser/messageCartoucheRefresh.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Production Messages renderer/CSS with delayed synthetic HTTP data; no live effects.
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 evidence = process.argv[2] || '.run/message-cartouche-refresh';
fs.mkdirSync(evidence, { recursive: true });
let revision = 0;
const message = (id, date, content) => ({ concept_id: id, created_at: date,
relationships: { '#V#has_sender': ['#V#bob'] },
concept_data: { content_fallback: content, subject: revision === 2 ? 'Updated subject' : '' } });
const server = http.createServer(async (req, res) => {
if (req.url === '/') {
res.setHeader('Content-Type', 'text/html');
return res.end('<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/static/styles.css"><div id="conversationWorkspace" class="show-message-exchange"><div id="messagesContainer"></div></div>');
}
if (req.url.startsWith('/static/')) {
const file = path.resolve(root, `.${req.url}`);
if (file.startsWith(root + '/static/') && fs.existsSync(file)) {
res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript');
return res.end(fs.readFileSync(file));
}
}
res.setHeader('Content-Type', 'application/json');
if (req.url === '/api/messages/exchange') {
let body = ''; for await (const chunk of req) body += chunk;
const { before } = JSON.parse(body);
const messages = before ? [message('#V#older', '2026-09-13T12:00:00Z', 'Earlier #V#older_concept')]
: [message('#V#recent', '2026-09-14T12:00:00Z', 'Task: #V#research_task')];
if (!before && revision === 1) messages.push(message('#V#new', '2026-09-15T12:00:00Z', 'New #V#new_concept'));
return setTimeout(() => res.end(JSON.stringify({ current_user_id: '#V#alice', messages, before: before ? null : 'earlier' })), 100);
}
if (req.url.startsWith('/vontology/api/vontology/node_content')) {
return setTimeout(() => res.end(JSON.stringify({ display_name: 'Research task', kind: 'individual', raw_doc: { names: [{ name: 'Research task', language: 'en-NZ', type: 'NL' }] } })), 250);
}
res.end(JSON.stringify({ success: true, profiles: [], messages: [] }));
});
(async () => {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const browser = await chromium.launch();
try {
const results = [];
for (const width of [390, 1280]) {
revision = 0;
const page = await browser.newPage({ viewport: { width, height: 850 } });
const errors = []; page.on('pageerror', error => errors.push(error.message));
await page.goto(`http://127.0.0.1:${server.address().port}`);
await page.evaluate(async () => {
window.IntersectionObserver = undefined;
window.panel = await import('/static/js/components/messagePanel.js');
await window.panel.openMessageExchange({ session_id: 'fixture', viewer_id: '#V#alice', participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'], session_name: 'Cartouche fixture' });
window.original = document.querySelector('.vontology-cartouche');
window.bubble = window.original.closest('.message-bubble');
window.removals = 0;
window.observer = new MutationObserver(records => {
for (const record of records) for (const node of record.removedNodes) {
if (node === window.original || node.contains?.(window.original)) window.removals++;
}
});
window.observer.observe(document.querySelector('#messageViewContent'), { childList: true, subtree: true });
});
await expect(page.locator('.vontology-cartouche-name')).toHaveText('Research task');
await page.getByRole('button', { name: 'Load earlier messages', exact: true }).click();
await expect(page.locator('.message-bubble')).toHaveCount(2);
await expect.poll(() => page.evaluate(() => document.querySelector('#messageViewContent').textContent.includes('…'))).toBe(false);
const pagination = await page.evaluate(() => ({ connected: window.original.isConnected, removals: window.removals, text: window.original.textContent }));
assert.equal(pagination.connected, true, 'pagination retains hydrated cartouche node');
assert.equal(pagination.removals, 0, 'pagination must not detach the existing cartouche');
revision = 1;
await page.evaluate(() => window.panel.refreshOpenMessageExchange());
await expect(page.locator('.message-bubble')).toHaveCount(3);
assert.deepEqual(await page.evaluate(() => ({ connected: window.original.isConnected, removals: window.removals })), { connected: true, removals: 0 });
await page.screenshot({ path: path.join(evidence, `stable-${width}.png`) });
// Same body but changed metadata must update; deleted recent rows disappear.
revision = 2;
await page.evaluate(() => window.panel.refreshOpenMessageExchange());
await expect(page.locator('[data-contribution-id="#V#recent"] .message-subject')).toHaveText('Updated subject');
await expect(page.locator('[data-contribution-id="#V#new"]')).toHaveCount(0);
assert.deepEqual(errors, []);
results.push({ width, pagination, refreshRetainsNode: true, metadataEditAndDeletion: true, errors });
await page.close();
}
fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify({ fixture: true, authenticatedLiveAcceptance: false, results }, null, 2));
} finally { await browser.close(); server.close(); }
})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });
Loading