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
41 changes: 40 additions & 1 deletion src/frontend/web/von_interface/static/js/components/taskPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2821,6 +2840,12 @@ async function openTaskWorkProduct(taskId) {
}
}

function renderTaskCopyLink(taskId) {
return `<span class="task-copy-link-control"><button type="button" class="copy-id-button task-copy-link-btn"
data-task-id="${escapeHtml(taskId)}" title="Copy link" aria-label="Copy link">📋</button>
<span class="sr-only" role="status" aria-live="polite"></span></span>`;
}

function renderTaskDetailsPanel(task, detailState) {
const taskId = getTaskId(task);
const detailTask = detailState.task || task;
Expand Down Expand Up @@ -3130,7 +3155,7 @@ function renderTaskInspector(task, detailState) {
<div class="task-inspector-header">
<div class="task-inspector-heading">
<div class="task-inspector-eyebrow">${escapeHtml(detailTask.reference_code || taskId)}</div>
<h3>${escapeHtml(detailTask.title || 'Untitled task')}</h3>
<h3>${escapeHtml(detailTask.title || 'Untitled task')} ${renderTaskCopyLink(taskId)}</h3>
<p>${escapeHtml(detailTask.description || 'No description yet.')}</p>
</div>
<div class="task-inspector-badges">
Expand Down Expand Up @@ -3497,6 +3522,7 @@ function renderTaskItem(task) {
<span class="task-priority" title="Priority: ${priorityInfo.label}">${priorityInfo.icon}</span>
${referenceCodeHtml}
${titleHtml}
${!_isGlobalTabMode ? renderTaskCopyLink(taskId) : ''}
<span class="task-status-badge" title="${escapeHtml(statusInfo.description || statusInfo.label)}">${statusInfo.icon} ${escapeHtml(statusInfo.label)}</span>
</div>
<div class="task-item-body">
Expand Down Expand Up @@ -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();
Expand Down
11 changes: 11 additions & 0 deletions src/frontend/web/von_interface/static/js/utils/taskLinks.js
Original file line number Diff line number Diff line change
@@ -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') || '';
}
33 changes: 33 additions & 0 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
127 changes: 127 additions & 0 deletions tests/browser/taskCopyLink.cjs
Original file line number Diff line number Diff line change
@@ -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(' <div id="taskPanel"'), template.indexOf(' <div id="taskPanel"') + template.slice(template.indexOf(' <div id="taskPanel"')).indexOf('\n </div>') + 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(`<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/styles.css"></head><body><button id="opener">Task reference</button><button id="outside">Outside</button>${popup}<button class="tab-button" data-tab="globalTasksTab">Tasks</button><div id="globalTasksTab" class="tab-content"><div id="globalTasksContainer"></div></div><script type="module">import {openTaskInPanel,showGlobalTasks} from '/js/components/taskPanel.js'; if(location.hash === '#globalTasksTab') { const {activateTab} = await import('/js/tabNavigation.js'); activateTab('globalTasksTab'); await showGlobalTasks(); } document.querySelector('#opener').onclick=()=>openTaskInPanel('#V#task_agent_8f35430789702d95864e5def732c43cd');</script></body></html>`);
} 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; });
Loading
Loading