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
@@ -0,0 +1,54 @@
# Messages copy icon and UI opportunity audit

- Kind: dated implementation and acceptance record
- Task: `#V#task_agent_27a0e3d1ae4e3b36411438c649870271`
- Scope: Messages copy affordance; bounded UI audit, 14 September 2026
- Source: `#V#computer_file_copy_ee0e4aeb7f354f8ca9996c01e816759e`,
image.png, 524×240, SHA-256
`4dcaeed7b3bfe3bd787e902ed662d6728fb424b9bd7b1119efdb5b95ad5a38bb`.
Controller-staged bytes were available and inspected; this was a referenced
file copy, not a native task attachment.

## Outcome and decisions

The screenshot's Messages Copy button now uses the overlapping-sheets outline
already used by the concept tab copy control. This familiar cross-platform
shape needs no OS detection or platform-dependent emoji. The native button,
accessible name and Markdown payload remain. A title supplies hover guidance;
touch/coarse-pointer devices retain visible Copy text and a 44px target.
Desktop targets are at least 32px. Keyboard focus has an explicit outline.
A separate live status shows Copied or Copy failed without removing the icon or
changing the action's name. The existing shared clipboard fallback now restores
focus after removing its temporary textarea.

| Candidate inspected | Decision and rationale |
| --- | --- |
| Messages bubble Copy (`messagePanel.js`) | Changed: the adjacent message is an unambiguous target; preserve touch text and accessible guidance. |
| Discuss with Von / Review recommendations (`messagePanel.js`) | Retained text: neither has a standard icon that explains its product-specific action. |
| Message composition and send controls (`messagePanel.js`) | Existing plus icons already support compact composition; retain Send because it commits the message. |
| Copy transcript (Markdown), Copy information (JSON), Copy JSON, Copy ID, Copy reference (`chat_tab.html`, `chatTab.js`, `annotationTab.js`) | Retained text: users need to distinguish payload and scope; identical copy icons would conceal this distinction. |
| Concept summary Copy (`conceptSummaryRendererPanel.js`) | Retained text: it copies full text, which may extend beyond the visible summary. |
| Task Copy link (`taskPanel.js`) | Already an icon with a title and accessible name; no additional replacement needed. |
| Refresh controls (`chat_tab.html`) | Retained text: refresh scope varies between situation, reference inspector and task panels; no demonstrated discoverability benefit from removing the label. |
| Concept tab copy (`dynamicTabs.js`) | Already uses overlapping sheets; reused its visual convention. |

This is a bounded audit of the adjacent messaging, chat, task and concept
surfaces, not a claim that every button in Von was inspected.

## Acceptance (Tier 1)

- 18 tests passed in `messageExchangeView.test.js` and
`messagePanelCartoucheRender.test.js`.
- Changed-module static frontend lint and `git diff --check` passed.
- `tests/browser/messageCopyIcon.cjs` passed using Chromium, the actual Messages
renderer and stylesheet, and synthetic HTTP/clipboard responses at 320px
touch and 1280px desktop.
- Browser assertions cover sent/received icons, names/titles, touch text and
target size, visible keyboard focus, Enter and Space, exact original Markdown,
clipboard fallback success/failure, restored focus and persistent icons.
- Screenshots inspected: `.run/message-copy/copy-320.png` and `copy-1280.png`;
machine-readable evidence: `.run/message-copy/result.json`.

Merge decision: ready after the above evidence. No live authentication, real
message writes, OS clipboard integration or public deployment was exercised.
No deployment was requested by this task.
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ const NEW_MESSAGE_DELIVERY_ATTEMPT_SCHEMA_VERSION = 'direct_message_new_delivery
const MESSAGE_THREAD_PREDICATE_ID = '#V#is_part_of_thread';

const MESSAGE_PANEL_ICONS = Object.freeze({
copy: '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2"/></svg>',
chat: '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M21 12a8 8 0 0 1-8 8H7l-4 3v-6.2A7.8 7.8 0 0 1 5 4.8 8 8 0 0 1 13 4a8 8 0 0 1 8 8Z"/><path d="M8 10h8"/><path d="M8 14h5"/></svg>',
compose: '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 5v14"/><path d="M5 12h14"/></svg>',
mail: '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="m4 7 8 6 8-6"/></svg>',
Expand Down Expand Up @@ -1157,7 +1158,8 @@ async function renderMessages() {
` : ''}
<div class="message-meta">
<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">Copy</button>
<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"
data-concept-id="${escapeHtml(msg.concept_id)}"
title="Discuss this message with Von">Discuss with Von</button>` : ''}
Expand Down Expand Up @@ -1197,7 +1199,16 @@ async function renderMessages() {
}
});
});
contentEl.querySelectorAll('.message-copy-markdown').forEach(button => { button.onclick = async () => { const message = _currentMessages.find(m => m.concept_id === button.dataset.messageId); if (message) { const copied = await copyTextWithClipboardFallback(message.concept_data?.content_fallback || ''); button.textContent = copied ? 'Copied' : 'Copy failed'; } }; });
contentEl.querySelectorAll('.message-copy-markdown').forEach(button => {
button.onclick = async () => {
const message = _currentMessages.find(m => m.concept_id === button.dataset.messageId);
if (!message) return;
const status = button.parentElement.querySelector('.message-copy-status');
status.textContent = '';
const copied = await copyTextWithClipboardFallback(message.concept_data?.content_fallback || '');
status.textContent = copied ? 'Copied' : 'Copy failed';
};
});
contentEl.querySelectorAll('.message-discuss-btn').forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ function copyTextFallback(text) {
return false;
}

const previousFocus = document.activeElement;
const textArea = document.createElement('textarea');
textArea.value = String(text ?? '');
textArea.style.position = 'fixed';
Expand All @@ -228,6 +229,9 @@ function copyTextFallback(text) {
copied = false;
} finally {
document.body.removeChild(textArea);
if (previousFocus?.isConnected && typeof previousFocus.focus === 'function') {
previousFocus.focus({ preventScroll: true });
}
}
return copied;
}
Expand Down
35 changes: 35 additions & 0 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -4465,6 +4465,41 @@ button.unified-search-conversation-main {
}
}

.message-copy-markdown {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
gap: 5px;
min-width: 32px;
min-height: 32px;
padding: 5px;
}

.message-copy-markdown svg {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
}

.message-copy-label {
display: none;
}

.message-copy-markdown:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}

/* Touch users cannot hover for the native tooltip. */
@media (hover: none), (pointer: coarse) {
.message-copy-label { display: inline; }
.message-copy-markdown { min-width: 44px; min-height: 44px; }
}

.message-empty-state {
display: flex;
flex-direction: column;
Expand Down
86 changes: 86 additions & 0 deletions tests/browser/messageCopyIcon.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Actual Messages renderer and CSS; synthetic HTTP data, no live state changes.
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-copy';
fs.mkdirSync(evidence, { recursive: true });
const content = '**Research update**\nCopy the original Markdown.';
const server = http.createServer((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') return res.end(JSON.stringify({ current_user_id: '#V#alice', messages: ['alice', 'bob'].map(sender => ({
concept_id: `#V#message_${sender}`, created_at: '2026-09-14T12:23:00Z',
relationships: { '#V#has_sender': [`#V#${sender}`] },
concept_data: { content_fallback: content, read_by: ['#V#alice'] }
})) }));
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 [320, 1280]) {
const page = await browser.newPage({ viewport: { width, height: 850 }, hasTouch: width < 800 });
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.copied = [];
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => window.copied.push(text) } });
const { openMessageExchange } = await import('/static/js/components/messagePanel.js');
await openMessageExchange({ session_id: 'fixture', viewer_id: '#V#alice', participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'], session_name: 'Copy icon fixture' });
});
const buttons = page.getByRole('button', { name: 'Copy message as Markdown', exact: true });
await expect(buttons).toHaveCount(2);
for (const button of await buttons.all()) {
await expect(button).toHaveAttribute('title', 'Copy message as Markdown');
await expect(button.locator('svg')).toBeVisible();
if (width < 800) await expect(button.locator('.message-copy-label')).toBeVisible();
else await expect(button.locator('.message-copy-label')).toBeHidden();
const box = await button.boundingBox();
assert(box.width >= (width < 800 ? 44 : 32) && box.height >= (width < 800 ? 44 : 32));
}
await buttons.first().focus();
assert.notEqual(await buttons.first().evaluate(el => getComputedStyle(el).outlineStyle), 'none');
await page.screenshot({ path: path.join(evidence, `copy-${width}.png`) });
await page.keyboard.press('Enter');
await expect(page.locator('.message-copy-status').first()).toHaveText('Copied');
await expect(buttons.first()).toBeFocused();
assert.equal(await page.evaluate(() => window.copied[0]), content);
await page.keyboard.press('Space');
await expect.poll(() => page.evaluate(() => window.copied.length)).toBe(2);
// Force fallback, checking actual selected text and restored focus.
await page.evaluate(() => {
navigator.clipboard.writeText = async () => { throw new Error('Clipboard unavailable'); };
document.execCommand = () => { window.fallbackText = document.activeElement.value; return true; };
});
await page.keyboard.press('Enter');
await expect.poll(() => page.evaluate(() => window.fallbackText)).toBe(content);
await expect(buttons.first()).toBeFocused();
await page.evaluate(() => { document.execCommand = () => false; });
await page.keyboard.press('Enter');
await expect(page.locator('.message-copy-status').first()).toHaveText('Copy failed');
await expect(buttons.first()).toBeFocused();
await expect(buttons.first().locator('svg')).toBeVisible();
await expect(page.getByRole('button', { name: 'Discuss with Von', exact: true })).toHaveCount(2);
assert.deepEqual(errors, []);
results.push({ width, sentAndReceived: true, keyboardCopy: true, rawMarkdown: true, fallbackFocus: true, visibleFailure: true, touchTextFallback: width < 800 });
await page.close();
}
fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify({ fixture: true, results }, null, 2));
} finally { await browser.close(); server.close(); }
})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });
Loading