diff --git a/src/frontend/web/von_interface/static/js/components/taskPanel.js b/src/frontend/web/von_interface/static/js/components/taskPanel.js index 4ca9bd7d..c38b7490 100644 --- a/src/frontend/web/von_interface/static/js/components/taskPanel.js +++ b/src/frontend/web/von_interface/static/js/components/taskPanel.js @@ -5,6 +5,8 @@ * Tasks are stored as Vontology concepts and accessed via REST API. */ +import { copyJsonTextWithButtonFeedback } from '../utils/copyJsonButtonState.js'; +import { buildTaskLink, readTaskLink } from '../utils/taskLinks.js'; import { openTaskRunActivity } from './taskRunActivity.js'; import { deleteJson, getJson, patchJson, postJson, getUserContext, ensureUniqueWindowSessionId, WINDOW_SESSION_HEADER } from '../apiService.js'; import { activateTab } from '../tabNavigation.js'; @@ -32,6 +34,7 @@ let _taskDetailState = {}; // taskId -> detail panel state let _selectedTaskId = ''; let _selectionCleared = false; let _pendingTaskNavigation = null; +let _taskLinkConsumed = false; let _referencedGlobalTaskId = ''; let _panelReturnFocus = null; let _panelTaskIds = null; @@ -964,6 +967,22 @@ async function openGlobalTasks() { loadGlobalTasks({ telemetry }), loadTaskQueueActivity(), ]); + const linkedTaskId = !_taskLinkConsumed && readTaskLink(); + if (!_pendingTaskNavigation && linkedTaskId) { + _taskLinkConsumed = true; + const scopeGeneration = _taskQueueActivityGeneration; + const loadGeneration = _globalTaskLoadGeneration; + try { + const task = await getJson(`/api/tasks/${encodeURIComponent(linkedTaskId)}`); + if (scopeGeneration !== _taskQueueActivityGeneration || loadGeneration !== _globalTaskLoadGeneration) return; + if (getTaskId(task) !== linkedTaskId) throw new Error('Task link target mismatch'); + _pendingTaskNavigation = task; + } catch (_) { + if (scopeGeneration !== _taskQueueActivityGeneration || loadGeneration !== _globalTaskLoadGeneration) return; + clearTaskSelection(); + showToast('Could not open linked task', 'error'); + } + } if (_pendingTaskNavigation) { const task = _pendingTaskNavigation; _pendingTaskNavigation = null; @@ -2821,6 +2840,12 @@ async function openTaskWorkProduct(taskId) { } } +function renderTaskCopyLink(taskId) { + return ` + `; +} + function renderTaskDetailsPanel(task, detailState) { const taskId = getTaskId(task); const detailTask = detailState.task || task; @@ -3130,7 +3155,7 @@ function renderTaskInspector(task, detailState) {
${escapeHtml(detailTask.reference_code || taskId)}
-

${escapeHtml(detailTask.title || 'Untitled task')}

+

${escapeHtml(detailTask.title || 'Untitled task')} ${renderTaskCopyLink(taskId)}

${escapeHtml(detailTask.description || 'No description yet.')}

@@ -3497,6 +3522,7 @@ function renderTaskItem(task) { ${priorityInfo.icon} ${referenceCodeHtml} ${titleHtml} + ${!_isGlobalTabMode ? renderTaskCopyLink(taskId) : ''} ${statusInfo.icon} ${escapeHtml(statusInfo.label)}
@@ -3788,6 +3814,19 @@ function attachTaskEventListeners() { }); }); + root.querySelectorAll('.task-copy-link-btn').forEach((button) => { + button.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + const copied = await copyJsonTextWithButtonFeedback(button, buildTaskLink(button.dataset.taskId), { + successLabel: '✔', errorLabel: '✕', fallbackLabel: '📋', + }); + button.parentElement.querySelector('[role="status"]').textContent = copied ? 'Link copied to clipboard' : 'Copy failed'; + button.focus({ preventScroll: true }); + showToast(copied ? 'Link copied to clipboard' : 'Copy failed', copied ? 'success' : 'error'); + }); + }); + root.querySelectorAll('.task-concept-link').forEach((btn) => { btn.addEventListener('click', (e) => { e.preventDefault(); diff --git a/src/frontend/web/von_interface/static/js/utils/taskLinks.js b/src/frontend/web/von_interface/static/js/utils/taskLinks.js new file mode 100644 index 00000000..c1e0ad0a --- /dev/null +++ b/src/frontend/web/von_interface/static/js/utils/taskLinks.js @@ -0,0 +1,11 @@ +/** Share a task through the existing Tasks tab; identifiers stay losslessly encoded. */ +export function buildTaskLink(taskId) { + const url = new URL(window.location.pathname, window.location.origin); + url.searchParams.set('task', taskId); + url.hash = 'globalTasksTab'; + return url.href; +} + +export function readTaskLink() { + return new URL(window.location.href).searchParams.get('task') || ''; +} diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 52868c23..f83d53dd 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18793,3 +18793,36 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu #chatSubmitModeHelp { display: block; flex-basis: 100%; font-size: 12px; margin: 0; } .unified-message-content .message-view-header { flex-wrap: nowrap; } + +/* Keep the concept copy glyph tiny, with a usable task-detail hit target. */ +.task-copy-link-btn { + min-width: 32px; + min-height: 32px; + font-size: 14px; + vertical-align: middle; +} + +.task-copy-link-btn, +.task-copy-link-btn:hover { + color: inherit; + background: transparent; + transform: none; +} + +.task-copy-link-btn:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.task-copy-link-control { + display: inline-flex; + flex-shrink: 0; + vertical-align: middle; +} + +@media (pointer: coarse) { + .task-copy-link-btn { + min-width: 44px; + min-height: 44px; + } +} diff --git a/tests/browser/taskCopyLink.cjs b/tests/browser/taskCopyLink.cjs new file mode 100644 index 00000000..32936c3b --- /dev/null +++ b/tests/browser/taskCopyLink.cjs @@ -0,0 +1,127 @@ +/** Tier 1 fixture: candidate task component, template and CSS; synthetic read-only tasks. */ +const { chromium, expect } = require('@playwright/test'); +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const root = path.resolve('src/frontend/web/von_interface/static'); +const output = path.resolve(process.argv[2] || '.run/task-copy-link'); +const task = { task_concept_id: '#V#task_agent_8f35430789702d95864e5def732c43cd', title: 'Represent deployed Von builds and link deployments to tasks', description: 'long_unbroken_research_reference_'.repeat(12), status: 'completed', priority: 'medium' }; +const template = fs.readFileSync('src/frontend/web/von_interface/templates/chat_tab.html', 'utf8'); +const popup = template.slice(template.indexOf('
') + 9); +const stubs = { + '/js/workflowStudioAccess.js': 'export const canUseWorkflowStudio=()=>false; export const setupWorkflowStudioAccess=()=>{}; ', + '/js/conceptTab.js': 'export const fetchConceptList=()=>{}; export const initializeConceptTab=()=>{}; export const updateConceptTabUI=()=>{};', + '/js/dynamicTabs.js': 'export const initializeDynamicTabs=()=>{}; export const loadDynamicConceptTabContent=()=>{};', + '/js/importExportTab.js': 'export const initializeImportExportTab=()=>{};', + '/js/vontology.js': 'export const initializeVontologyTab=()=>{};', + + '/js/apiService.js': `export const getWindowSessionId=()=> 'fixture'; export const WINDOW_SESSION_HEADER='X-Von-Window-Session'; export const ensureUniqueWindowSessionId=async()=> 'fixture'; export const getUserContext=()=>({user_id:'#V#fixture',org_id:'#V#fixture_org'}); export const getJson=async url=>(await fetch(url)).json(); export const getJsonDetailed=async url=>({data:await getJson(url)}); export const postJson=()=>{throw Error('Unexpected write')}; export const patchJson=postJson; export const deleteJson=postJson;`, + '/js/markdownUtils.js': 'export const renderMarkdownViaServer=async()=>"";', +}; +const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/') { + res.setHeader('Content-Type', 'text/html'); + res.end(`${popup}
`); + } else if (stubs[url.pathname]) { + res.setHeader('Content-Type', 'text/javascript'); res.end(stubs[url.pathname]); + } else if (url.pathname.startsWith('/api/')) { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(decodeURIComponent(url.pathname) === '/api/tasks/#V#task_agent_8f35430789702d95864e5def732c43cd' ? task : url.pathname === '/api/tasks/' ? { tasks: [] } : {})); + } else { + const file = path.join(root, url.pathname); + if (!file.startsWith(root + '/') || !fs.existsSync(file)) { res.statusCode = 404; res.end(); return; } + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript'); + res.end(fs.readFileSync(file)); + } +}); +(async () => { + fs.mkdirSync(output, { recursive: true }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + let browser; + const measurements = []; + try { + browser = await chromium.launch(); + const errors = []; + for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }, { width: 320, height: 568 }]) { + const context = await browser.newContext({ viewport, hasTouch: viewport.width < 500, permissions: ['clipboard-read', 'clipboard-write'] }); + const page = await context.newPage(); + page.on('pageerror', error => { errors.push(error.message); console.error(error.message); }); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + await page.getByRole('button', { name: 'Task reference', exact: true }).click(); + const panel = page.getByRole('dialog', { name: 'Conversation tasks', exact: true }); + await expect(panel.locator('.task-detail-toggle-btn')).toHaveAttribute('aria-expanded', 'true'); + const copy = panel.getByRole('button', { name: 'Copy link', exact: true }); + const hitTarget = await copy.boundingBox(); + const minimumTarget = viewport.width < 500 ? 44 : 32; + assert(hitTarget.width >= minimumTarget && hitTarget.height >= minimumTarget); + await copy.focus(); + await copy.press('Enter'); + await expect(panel.getByRole('status')).toHaveText('Link copied to clipboard'); + const copiedUrl = await page.evaluate(() => navigator.clipboard.readText()); + assert.equal(new URL(copiedUrl).searchParams.get('task'), task.task_concept_id); + assert.equal(new URL(copiedUrl).hash, '#globalTasksTab'); + await expect(panel).toBeVisible(); + await expect(copy).toBeFocused(); + const before = await panel.boundingBox(); + await page.getByRole('button', { name: 'Hide details', exact: true }).click(); + const details = page.getByRole('button', { name: 'Show details', exact: true }); + await expect(details).toHaveAttribute('aria-expanded', 'false'); + await expect(details).toBeFocused(); + const after = await panel.boundingBox(); + assert(Math.abs(before.height - after.height) < 1 && Math.abs(before.width - after.width) < 1, 'details do not resize popup'); + const overflow = await panel.evaluate(el => el.scrollWidth - el.clientWidth); + const bodyOverflow = await panel.locator('.llm-popup-body').evaluate(el => el.scrollWidth - el.clientWidth); + assert(before.x >= 0 && before.x + before.width <= viewport.width + 1 && before.y + before.height <= viewport.height, 'popup stays inside viewport'); + assert(overflow <= 1 && bodyOverflow <= 1, 'no horizontal clipping'); + await details.press('Enter'); + await expect(panel.locator('.task-detail-toggle-btn')).toHaveAttribute('aria-expanded', 'true'); + await expect(panel.locator('.task-detail-loading')).toHaveCount(0); + await expect(panel.locator('.task-detail-toggle-btn')).toBeFocused(); + await page.screenshot({ path: path.join(output, `popup-${viewport.width}.png`) }); + await page.keyboard.press('Escape'); + await expect(panel).toBeHidden(); + await expect(page.locator('#opener')).toBeFocused(); + await page.locator('#opener').click(); + await page.getByRole('button', { name: 'Close Tasks popup' }).press('Space'); + await expect(panel).toBeHidden(); + await page.locator('#opener').click(); + await page.locator('#outside').click(); + await expect(panel).toBeHidden(); + await page.locator('#opener').click(); + await page.getByRole('button', { name: 'Open in Tasks', exact: true }).press('Enter'); + await expect(panel).toBeHidden(); + await expect(page.locator('#globalTaskInspector')).toContainText(task.title); + await expect(page.locator('#globalTaskInspector')).toBeFocused(); + assert.equal(await page.evaluate(() => location.hash), '#globalTasksTab'); + const inspector = page.locator('#globalTaskInspector'); + const pageCopy = inspector.getByRole('button', { name: 'Copy link', exact: true }); + await pageCopy.click(); + await expect(inspector.getByRole('status')).toHaveText('Link copied to clipboard'); + assert.equal(await pageCopy.evaluate(el => getComputedStyle(el).backgroundColor), 'rgba(0, 0, 0, 0)'); + assert.equal(await page.evaluate(() => navigator.clipboard.readText()), copiedUrl); + await page.evaluate(() => { + window.originalWrite = navigator.clipboard.writeText.bind(navigator.clipboard); + navigator.clipboard.writeText = async () => { throw Error('Clipboard denied'); }; + document.execCommand = () => false; + }); + await pageCopy.click(); + await expect(inspector.getByRole('status')).toHaveText('Copy failed'); + await expect(page.locator('.toast-error')).toHaveText('Copy failed'); + await page.evaluate(() => { navigator.clipboard.writeText = window.originalWrite; }); + await pageCopy.click(); + await expect(inspector.getByRole('status')).toHaveText('Link copied to clipboard'); + await page.screenshot({ path: path.join(output, `page-${viewport.width}.png`) }); + await page.goto(copiedUrl); + await expect(page.locator('#globalTaskInspector .task-inspector-card')).toBeVisible(); + await expect(page.locator('#globalTaskInspector .task-inspector-card')).toHaveAttribute('data-task-id', task.task_concept_id); + await expect(page.locator('.task-item.is-selected')).toHaveAttribute('data-task-id', task.task_concept_id); + measurements.push({ viewport, hitTarget, before, after, overflow, bodyOverflow, copiedUrl }); + await context.close(); + } + assert.deepEqual(errors, []); + fs.writeFileSync(path.join(output, 'measurements.json'), JSON.stringify(measurements, null, 2)); + console.log(JSON.stringify(measurements)); + } finally { await browser?.close(); server.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/frontend/taskPanelCopyLink.test.js b/tests/frontend/taskPanelCopyLink.test.js new file mode 100644 index 00000000..8ef02096 --- /dev/null +++ b/tests/frontend/taskPanelCopyLink.test.js @@ -0,0 +1,77 @@ +/** @jest-environment jsdom */ +jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ + getJson: jest.fn(), getUserContext: jest.fn(() => ({})), + postJson: jest.fn(), patchJson: jest.fn(), deleteJson: jest.fn(), +})); +jest.mock('../../src/frontend/web/von_interface/static/js/tabNavigation.js', () => ({ activateTab: jest.fn() })); +jest.mock('../../src/frontend/web/von_interface/static/js/utils/toast.js', () => ({ showToast: jest.fn() })); +const base = '../../src/frontend/web/von_interface/static/js/'; +const taskId = '#V#task_agent_8f35430789702d95864e5def732c43cd'; +const task = { task_concept_id: taskId, title: 'Represent deployed Von builds', status: 'completed' }; +const settle = () => new Promise(resolve => setTimeout(resolve, 0)); +beforeEach(() => { + jest.resetModules(); + window.history.replaceState({}, '', '/von/'); + document.body.innerHTML = '
'; + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockResolvedValue() } }); + document.execCommand = jest.fn(() => false); + require(base + 'apiService.js').getJson.mockImplementation(async url => + url === `/api/tasks/${encodeURIComponent(taskId)}` ? task : {}); +}); +afterEach(() => jest.restoreAllMocks()); + +test.each(['popup', 'page'])('%s copies the exact task URL with feedback and no task writes', async surface => { + const panel = require(base + 'components/taskPanel.js'); + await panel.openTaskInPanel(taskId); + if (surface === 'page') { + document.querySelector('.task-open-in-tab-btn').click(); + await panel.showGlobalTasks(); + } + const root = document.querySelector(surface === 'popup' ? '#taskPanel' : '#globalTaskInspector'); + const button = root.querySelector('[aria-label="Copy link"]'); + expect(button.classList.contains('copy-id-button')).toBe(true); + button.focus(); + button.click(); + await settle(); + const copied = navigator.clipboard.writeText.mock.calls[0][0]; + expect(copied).toBe(`http://localhost/von/?task=${encodeURIComponent(taskId)}#globalTasksTab`); + expect(root.querySelector('[role="status"]').textContent).toBe('Link copied to clipboard'); + expect(document.activeElement).toBe(button); + if (surface === 'popup') expect(root.classList.contains('hidden')).toBe(false); + else expect(root.querySelector('.task-inspector-card').dataset.taskId).toBe(taskId); + const api = require(base + 'apiService.js'); + for (const method of ['postJson', 'patchJson', 'deleteJson']) expect(api[method]).not.toHaveBeenCalled(); +}); + +test('failed clipboard and failed legacy copy report failure; a later retry succeeds', async () => { + await require(base + 'components/taskPanel.js').openTaskInPanel(taskId); + navigator.clipboard.writeText.mockRejectedValueOnce(new Error('Denied')); + const button = document.querySelector('.task-copy-link-btn'); + button.click(); + await settle(); + expect(button.title).toBe('Copy failed'); + expect(document.querySelector('[role="status"]').textContent).toBe('Copy failed'); + expect(document.querySelector('body > textarea')).toBeNull(); + button.click(); + await settle(); + expect(button.title).toBe('Copied to clipboard'); +}); + +test('copied link resolves the exact task beyond the initial list through the task API', async () => { + const { buildTaskLink } = require(base + 'utils/taskLinks.js'); + window.history.replaceState({}, '', buildTaskLink(taskId)); + await require(base + 'components/taskPanel.js').showGlobalTasks(); + expect(document.querySelector('#globalTaskInspector .task-inspector-card').dataset.taskId).toBe(taskId); + expect(document.querySelector('.task-item.is-selected').dataset.taskId).toBe(taskId); +}); + +test('an inaccessible link reports failure without substituting an unrelated task', async () => { + window.history.replaceState({}, '', '/von/?task=%23V%23inaccessible#globalTasksTab'); + require(base + 'apiService.js').getJson.mockImplementation(async url => { + if (url === '/api/tasks/%23V%23inaccessible') throw new Error('Forbidden'); + return {}; + }); + await require(base + 'components/taskPanel.js').showGlobalTasks(); + expect(require(base + 'utils/toast.js').showToast).toHaveBeenCalledWith('Could not open linked task', 'error'); + expect(document.querySelector('.task-inspector-card')).toBeNull(); +});