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
26 changes: 26 additions & 0 deletions docs/engineering/unified_conversations_and_participant_profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,29 @@ the admin chat-history reindex route). The lexical backfill utility
projections only; it does not backfill embeddings. Inspect coverage and use bounded,
actor/namespace-specific reindexing rather than scanning history on every search.
This change does not run a live backfill or certify current production index coverage.

## Stored conversation turn references

Chat turns use the existing compact format
`#V#conversation_<identity>_turn_<UTF-8 hex of stored turn_id>`. The conversation
identity and persisted turn ID, rather than a title, timestamp or array position,
keep the reference stable across reloads, renaming and transcript reordering.
`POST /von/api/session/conversation_reference` resolves the source and stored
turn before returning the reference. The same reference can be opened as a
conversation cartouche or retrieved through `conversation_get`; access still
requires the reader's normal source permissions. It is not an access grant.

Right-click an individual user or assistant turn and choose **Copy turn
reference**, or focus the turn and press **Shift+F10** or the **Context Menu**
key. Escape closes the menu and returns focus. The existing **Copy turn link**
button provides direct and touch access to the same action. Links, editable
fields and selected text retain their native browser context menus. Entries
without an available stored turn ID report that fact without copying a
fabricated reference; client-only history IDs are not stable locators.

Coverage: `test_compact_conversation_reference.py` exercises generation,
resolution, renamed/reordered sources and unavailable/denied turns;
`chatTab.test.js` covers the renderer/menu integration; and
`tests/browser/turnReferenceMenu.cjs` checks the production frontend modules in
a local HTTP fixture with real browser clipboard access and reloads. That
fixture does not claim live authenticated-server or public deployment acceptance.
27 changes: 27 additions & 0 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -38272,6 +38272,32 @@ function createTurnReferenceCopyButton(sourceSessionId, turnId) {
return referenceButton;
}

function bindTurnReferenceMenu(container) {
const copyButton = container.querySelector('.conversation-turn-copy');
if (!copyButton) return;
container.tabIndex = 0;
container.setAttribute('aria-haspopup', 'menu');
container.setAttribute('aria-expanded', 'false');
container.setAttribute('aria-keyshortcuts', 'Shift+F10');
container.setAttribute('aria-description', 'Right-click or press Shift+F10 for turn options.');
const open = event => {
// Preserve native link, input and selected-text menus inside a turn.
if (event.target.closest('a, input, textarea, select, [contenteditable="true"]')
|| window.getSelection()?.toString()) return;
event.preventDefault();
event.stopPropagation();
const rect = container.getBoundingClientRect();
const pointer = event.type === 'contextmenu' && (event.clientX || event.clientY);
openChatSessionMenu(pointer ? event.clientX : rect.left, pointer ? event.clientY : rect.bottom, [
{ label: 'Copy turn reference', onClick: () => copyButton.click() }
], { returnFocus: container, focusFirst: true });
};
container.addEventListener('contextmenu', open);
container.addEventListener('keydown', event => {
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) open(event);
});
}

function appendMessage(sender, message, turnId, hasLlmDebug = false, isHistory = false, timestampStr = null, fastpathMeta = null, ttsText = null, messageOptions = null) {
const scrollableField = document.getElementById('scrollableField');
if (!scrollableField) {
Expand Down Expand Up @@ -38941,6 +38967,7 @@ function appendMessage(sender, message, turnId, hasLlmDebug = false, isHistory =

const renderedTurn = turnId ? Array.from(scrollableField.querySelectorAll('[data-turn-id]')).find(el => el.dataset.turnId === turnId) : null;
if (renderedTurn) {
bindTurnReferenceMenu(renderedTurn);
const liveRequest = liveChatRequestsBySession.get(getChatRequestSessionKey(activeChatSessionId));
const attachments = options.imageAttachments || (sender === 'User' ? descriptorsForIds(liveRequest?.executionEnvelope?.image_attachment_ids) : []);
renderImageAttachments(renderedTurn, attachments);
Expand Down
59 changes: 59 additions & 0 deletions src/frontend/web/von_interface/static/js/test/chatTab.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11363,6 +11363,65 @@ describe('chat message layout hooks', () => {
});
});

describe('turn reference context menu', () => {
const flushMicrotasks = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); };
test('copies stored sources and preserves keyboard and native interactions', async () => {
const { postJson } = require('../apiService.js');
const reference = '#V#conversation_fixture_turn_612d73746f726564';
postJson.mockResolvedValue({ success: true, concept_reference: reference });
const writeText = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
const previousMenu = document.querySelector('.chat-session-menu');
document.body.innerHTML = '<div id="scrollableField"></div>';
if (previousMenu) document.body.appendChild(previousMenu);
__testOnly_setActiveChatSession('source-session', 'Source');
for (const [sender, turnId] of [['User', 'u-stored'], ['Von', 'a-stored']]) {
__testOnly_appendMessage(sender, 'Saved text', turnId, false, true);
const turn = document.querySelector(`[data-turn-id="${turnId}"]`);
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 30, clientY: 40 });
turn.querySelector('.chat-message-text').dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
const item = document.querySelector('.chat-session-menu [role="menuitem"]');
expect(item.textContent).toBe('Copy turn reference');
expect(document.activeElement).toBe(item);
__testOnly_setActiveChatSession('other-session', 'Other');
item.click();
await flushMicrotasks();
expect(postJson).toHaveBeenLastCalledWith('/von/api/session/conversation_reference', {
session_id: 'source-session', metadata_only: true, turn_id: turnId
});
expect(writeText).toHaveBeenLastCalledWith(reference);
expect(document.activeElement).toBe(turn);
__testOnly_setActiveChatSession('source-session', 'Source');
}
const turn = document.querySelector('[data-turn-id="a-stored"]');
turn.dispatchEvent(new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true, cancelable: true }));
expect(document.querySelector('.chat-session-menu').classList.contains('open')).toBe(true);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(document.activeElement).toBe(turn);
const link = document.createElement('a');
link.href = '#example';
turn.appendChild(link);
const nativeMenu = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
link.dispatchEvent(nativeMenu);
expect(nativeMenu.defaultPrevented).toBe(false);
const selection = window.getSelection();
turn.querySelector('.chat-message-text').textContent = 'Selected answer';
selection.selectAllChildren(turn.querySelector('.chat-message-text'));
expect(selection.toString()).toBe('Selected answer');
const selectedMenu = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
turn.dispatchEvent(selectedMenu);
expect(selectedMenu.defaultPrevented).toBe(false);
selection.removeAllRanges();
const copies = writeText.mock.calls.length;
postJson.mockResolvedValue({ success: false });
turn.querySelector('.conversation-turn-copy').click();
await flushMicrotasks();
expect(writeText).toHaveBeenCalledTimes(copies);
__testOnly_setActiveChatSession(null, null);
});
});

describe('relation truth-state display elements', () => {
beforeEach(() => {
const { createVontologyCartouche, normalisePotentialConceptId } = require('../utils/textDecorator.js');
Expand Down
93 changes: 93 additions & 0 deletions tests/browser/turnReferenceMenu.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Local browser fixture: actual chat renderer, menu, clipboard and reference client.
// Server responses are fixtures; backend resolution is covered separately.
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 reference = '#V#conversation_fixture_turn_612d73746f726564';
const calls = [];
let unavailable = false;
const server = http.createServer((req, res) => {
if (req.url === '/') {
res.setHeader('Content-Type', 'text/html');
res.end('<!doctype html><link rel="stylesheet" href="/static/styles.css"><div id="scrollableField"></div>');
return;
}
if (req.url === '/von/api/session/conversation_reference') {
let body = '';
req.on('data', data => body += data);
req.on('end', () => {
calls.push(JSON.parse(body));
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(unavailable ? { success: false } : { success: true, concept_reference: reference }));
});
return;
}
const file = path.resolve(root, `.${req.url.split('?')[0]}`);
if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) {
res.setHeader('Content-Type', 'application/json');
res.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 {
const page = await browser.newPage({ permissions: ['clipboard-read', 'clipboard-write'] });
await page.goto(`http://127.0.0.1:${server.address().port}`);
async function render() {
await page.evaluate(async () => {
const chat = await import('/static/js/chatTab.js');
chat.__testOnly_setActiveChatSession('source-session', 'Source');
chat.__testOnly_appendMessage('User', 'Saved question', 'u-stored', false, true);
chat.__testOnly_appendMessage('Von', 'Saved answer', 'a-stored', false, true);
});
}
await render();
const turn = page.locator('.message-container[data-turn-id="a-stored"]');
await turn.locator('.chat-message-text').click({ button: 'right' });
await expect(page.getByRole('menuitem', { name: 'Copy turn reference' })).toBeFocused();
await page.getByRole('menuitem', { name: 'Copy turn reference' }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(reference);
assert.deepEqual(calls.at(-1), { session_id: 'source-session', metadata_only: true, turn_id: 'a-stored' });
await expect(turn).toBeFocused();
await page.reload();
await render();
await turn.focus();
await page.keyboard.press('Shift+F10');
await expect(page.getByRole('menuitem', { name: 'Copy turn reference' })).toBeFocused();
const evidence = process.argv[2];
if (evidence) {
fs.mkdirSync(evidence, { recursive: true });
await page.screenshot({ path: path.join(evidence, 'turn-reference-menu.png') });
}
await page.keyboard.press('Enter');
await expect.poll(() => calls.length).toBe(2);
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(reference);
await turn.focus();
await page.keyboard.press('Shift+F10');
await page.keyboard.press('Escape');
await expect(turn).toBeFocused();
unavailable = true;
await page.evaluate(() => navigator.clipboard.writeText('preserved'));
await turn.locator('.conversation-turn-copy').click();
await expect(page.getByText('This entry has no available stored turn reference.', { exact: true })).toBeVisible();
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), 'preserved');
unavailable = false;
await page.evaluate(() => {
navigator.clipboard.writeText = async () => { throw new Error('Clipboard denied'); };
document.execCommand = () => false;
});
await turn.locator('.conversation-turn-copy').click();
await expect(page.getByText('Could not copy turn reference.', { exact: true })).toBeVisible();
console.log('PASS: right-click, keyboard/focus, clipboard, reload stability, unavailable stored turn and clipboard failure');
} finally {
await browser.close();
server.close();
}
})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });
Loading