diff --git a/docs/engineering/unified_conversations_and_participant_profiles.md b/docs/engineering/unified_conversations_and_participant_profiles.md index 63ad88c9..e8901342 100644 --- a/docs/engineering/unified_conversations_and_participant_profiles.md +++ b/docs/engineering/unified_conversations_and_participant_profiles.md @@ -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__turn_`. 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. diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 6f1bcb84..322d4d89 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -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) { @@ -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); diff --git a/src/frontend/web/von_interface/static/js/test/chatTab.test.js b/src/frontend/web/von_interface/static/js/test/chatTab.test.js index 788237de..bc9f3f69 100644 --- a/src/frontend/web/von_interface/static/js/test/chatTab.test.js +++ b/src/frontend/web/von_interface/static/js/test/chatTab.test.js @@ -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 = '
'; + 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'); diff --git a/tests/browser/turnReferenceMenu.cjs b/tests/browser/turnReferenceMenu.cjs new file mode 100644 index 00000000..1dee6317 --- /dev/null +++ b/tests/browser/turnReferenceMenu.cjs @@ -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('
'); + 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; });