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
56 changes: 53 additions & 3 deletions src/frontend/web/von_interface/static/js/components/taskPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ let _filterTaskSourceId = 'all';
let _isGlobalTabMode = false; // True when rendering into global tasks tab
let _taskDetailState = {}; // taskId -> detail panel state
let _selectedTaskId = '';
let _pendingTaskNavigation = null;
let _referencedGlobalTaskId = '';
let _panelReturnFocus = null;
let _panelTaskIds = null;
let _panelLoadGeneration = 0;
let _bulkTaskVisibility = 'exclude';
Expand Down Expand Up @@ -182,6 +185,8 @@ function resetTaskPanelScopedState(activeOrganisationConceptId, { actorChanged =
_taskQueueActivityGeneration += 1;
stopTaskQueueActivityPolling();
_taskQueueActivityLoadPromise = null;
_pendingTaskNavigation = null;
_referencedGlobalTaskId = '';
_activeOrganisationConceptId = activeOrganisationConceptId;
_globalTaskLoadGeneration += 1;
_isLoading = false;
Expand Down Expand Up @@ -757,6 +762,20 @@ export function initializeTaskPanel() {
ensureTaskPanelAuthStatusListener();
renderTaskGroupFilterControls();

_panelEl.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
hideTaskPanel();
}
});
document.addEventListener('pointerdown', (event) => {
if (_isVisible && !_panelEl.contains(event.target)
&& !event.target.closest('#taskPanelToggleBtn')) {
hideTaskPanel({ restoreFocus: false });
}
});

// Set up close button
const closeBtn = document.getElementById('closeTaskPanel');
if (closeBtn) {
Expand Down Expand Up @@ -823,12 +842,14 @@ export function showTaskPanel(options = {}) {
_isGlobalTabMode = false;
syncTaskQueueActivityPolling();
_taskListEl = document.getElementById('taskList') || _taskListEl;
if (!_isVisible) _panelReturnFocus = document.activeElement;
_panelEl.classList.remove('hidden');
_panelEl.setAttribute('aria-hidden', 'false');
_panelEl.querySelectorAll('.task-create-form, .task-filter-row').forEach(element => {
element.classList.toggle('hidden', _panelTaskIds !== null);
});
_isVisible = true;
document.getElementById('closeTaskPanel')?.focus();
// Auto-refresh tasks when panel becomes visible
return refreshTasks();
}
Expand Down Expand Up @@ -870,11 +891,12 @@ async function loadReferencedPanelTasks() {
/**
* Hide the task panel.
*/
export function hideTaskPanel() {
export function hideTaskPanel({ restoreFocus = true } = {}) {
if (_panelEl) {
_panelEl.classList.add('hidden');
_panelEl.setAttribute('aria-hidden', 'true');
_isVisible = false;
if (restoreFocus && _panelReturnFocus?.isConnected) _panelReturnFocus.focus();
}
}

Expand Down Expand Up @@ -909,6 +931,7 @@ async function openGlobalTasks() {
_tasks = [];
_taskDetailState = {};
_selectedTaskId = '';
_referencedGlobalTaskId = '';
ensureTaskPanelOrganisationSwitchListener();
ensureTaskPanelAuthStatusListener();
ensureTaskQueueActivityPollingListeners();
Expand Down Expand Up @@ -939,6 +962,17 @@ async function openGlobalTasks() {
loadGlobalTasks({ telemetry }),
loadTaskQueueActivity(),
]);
if (_pendingTaskNavigation) {
const task = _pendingTaskNavigation;
_pendingTaskNavigation = null;
const taskId = getTaskId(task);
if (!_tasks.some(item => getTaskId(item) === taskId)) _tasks.push(task);
_referencedGlobalTaskId = taskId;
await selectTask(taskId);
const inspector = _globalTasksContainer.querySelector('#globalTaskInspector');
inspector?.setAttribute('tabindex', '-1');
inspector?.focus();
}
syncTaskQueueActivityPolling();
}

Expand Down Expand Up @@ -1508,6 +1542,7 @@ function getFilteredTasks() {
// Exact references remain reachable regardless of saved list filters.
if (!_isGlobalTabMode && _panelTaskIds !== null) return _tasks;
return _tasks.filter((task) => {
if (_isGlobalTabMode && getTaskId(task) === _referencedGlobalTaskId) return true;
if (!taskMatchesGlobalScope(task)) {
return false;
}
Expand Down Expand Up @@ -3440,9 +3475,10 @@ function renderTaskItem(task) {
<div class="task-actions">
${renderTaskExecuteWithVonButton(task)}
${renderTaskDiscussButton(task)}
<button class="task-detail-toggle-btn" data-task-id="${taskId}" title="${_isGlobalTabMode ? 'Inspect task details' : 'Show task details'}">
${_isGlobalTabMode ? 'Inspect' : (detailState.expanded ? 'Hide details' : 'Details')}
<button type="button" class="task-detail-toggle-btn" data-task-id="${taskId}" ${!_isGlobalTabMode ? `aria-expanded="${detailState.expanded}"` : ''} title="${_isGlobalTabMode ? 'Inspect task details' : (detailState.expanded ? 'Hide task details' : 'Show task details')}">
${_isGlobalTabMode ? 'Inspect' : (detailState.expanded ? 'Hide details' : 'Show details')}
</button>
${!_isGlobalTabMode ? `<button type="button" class="task-open-in-tab-btn btn-mini" data-task-id="${taskId}">Open in Tasks</button>` : ''}
<select class="task-status-select" data-task-id="${taskId}" title="Change status">
${TASK_STATUS_OPTIONS.map(opt => `
<option value="${opt.value}" ${task.status === opt.value ? 'selected' : ''}>
Expand Down Expand Up @@ -3739,6 +3775,18 @@ function attachTaskEventListeners() {
});
});

root.querySelectorAll('.task-open-in-tab-btn').forEach((button) => {
button.addEventListener('click', (event) => {
event.stopPropagation();
const task = _tasks.find(item => getTaskId(item) === button.dataset.taskId);
if (!task) return;
_pendingTaskNavigation = task;
hideTaskPanel({ restoreFocus: false });
activateTab('globalTasksTab');
void showGlobalTasks();
});
});

root.querySelectorAll('.task-detail-toggle-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
Expand All @@ -3748,6 +3796,8 @@ function attachTaskEventListeners() {
await selectTask(taskId);
} else {
await toggleTaskDetails(taskId);
Array.from(_panelEl.querySelectorAll('.task-detail-toggle-btn'))
.find(button => button.dataset.taskId === taskId)?.focus();
}
});
});
Expand Down
22 changes: 19 additions & 3 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1371,17 +1371,33 @@ body.von-signed-out {

.task-panel {
width: 480px;
max-width: 95vw;
max-height: 80vh;
box-sizing: border-box;
right: min(20px, 2vw);
max-width: 96vw;
height: min(720px, calc(100dvh - 90px));
max-height: calc(100dvh - 90px);
}

.task-panel .llm-popup-header {
flex-shrink: 0;
}

.task-panel .task-item,
.task-panel .task-actions,
.task-panel .task-detail-panel {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}

.task-panel .llm-popup-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
max-height: 70vh;
min-height: 0;
overflow-y: auto;
scrollbar-gutter: stable;
}

.task-panel-header-actions {
Expand Down
6 changes: 3 additions & 3 deletions src/frontend/web/von_interface/templates/chat_tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,12 @@ <h4>Workflow Discovery</h4>
</div>

<!-- Task Panel (JVNAUTOSCI-1040) -->
<div id="taskPanel" class="llm-popup hidden task-panel" aria-hidden="true">
<div id="taskPanel" class="llm-popup hidden task-panel" role="dialog" aria-label="Tasks" aria-hidden="true">
<div class="llm-popup-header">
<span>Tasks</span>
<div class="task-panel-header-actions">
<button id="refreshTasksBtn" class="btn-mini" title="Refresh tasks">🔄</button>
<button id="closeTaskPanel" class="btn-mini" title="Close">✕</button>
<button id="refreshTasksBtn" type="button" class="btn-mini" title="Refresh tasks" aria-label="Refresh tasks">Refresh</button>
<button id="closeTaskPanel" type="button" class="btn-mini" title="Close Tasks popup" aria-label="Close Tasks popup">Close</button>
</div>
</div>
<div class="llm-popup-body">
Expand Down
92 changes: 92 additions & 0 deletions tests/browser/taskPopupControls.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/** 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-popup-controls');
const task = { task_concept_id: '#V#popup_fixture', title: 'Completed research task with a long title', 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/utils/toast.js': 'export const showToast=()=>{};',
'/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} from '/js/components/taskPanel.js'; document.querySelector('#opener').onclick=()=>openTaskInPanel('#V#popup_fixture');</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#popup_fixture' ? 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 page = await browser.newPage();
const errors = []; page.on('pageerror', error => { errors.push(error.message); console.error(error.message); });
for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }, { width: 320, height: 568 }]) {
await page.setViewportSize(viewport);
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: 'Tasks', exact: true });
await expect(panel.locator('.task-detail-toggle-btn')).toHaveAttribute('aria-expanded', 'true');
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');
measurements.push({ viewport, before, after, overflow, bodyOverflow });
}
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; });
48 changes: 48 additions & 0 deletions tests/frontend/taskPanelReference.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,51 @@ test('an older task response cannot replace the most recently selected task', as
expect(document.getElementById('taskList').textContent).toContain('Latest selected task');
expect(document.getElementById('taskList').textContent).not.toContain('Earlier task');
});

test('popup exposes explicit close, keyboard dismissal, outside dismissal and detail state', async () => {
document.body.innerHTML = '<button id="opener">Task reference</button><button id="outside">Outside</button><div id="taskPanel" class="hidden"><button id="closeTaskPanel">Close</button><div id="taskList"></div></div>';
const { getJson } = require(base + 'apiService.js');
getJson.mockImplementation(async url => url === '/api/tasks/%23V%23exact_task' ? { task_concept_id: '#V#exact_task', title: 'Exact task', status: 'completed' } : {});
const panel = require(base + 'components/taskPanel.js');
const opener = document.getElementById('opener');
opener.focus();
await panel.openTaskInPanel('#V#exact_task');
expect(document.activeElement.id).toBe('closeTaskPanel');
const details = document.querySelector('.task-detail-toggle-btn');
expect(details.textContent.trim()).toBe('Hide details');
expect(details.getAttribute('aria-expanded')).toBe('true');
details.click();
await new Promise(resolve => setTimeout(resolve, 0));
expect(document.querySelector('.task-detail-toggle-btn').getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement.classList.contains('task-detail-toggle-btn')).toBe(true);
document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(document.getElementById('taskPanel').getAttribute('aria-hidden')).toBe('true');
expect(document.activeElement).toBe(opener);
await panel.openTaskInPanel('#V#exact_task');
document.getElementById('outside').dispatchEvent(new Event('pointerdown', { bubbles: true }));
expect(document.getElementById('taskPanel').classList.contains('hidden')).toBe(true);
await panel.openTaskInPanel('#V#exact_task');
document.getElementById('closeTaskPanel').click();
expect(document.getElementById('taskPanel').classList.contains('hidden')).toBe(true);
});

test('Open in Tasks closes the popup and selects the exact task beyond the first page and saved filters', async () => {
document.body.innerHTML = '<div id="taskPanel" class="hidden"><button id="closeTaskPanel">Close</button><select id="taskStatusFilter"><option value="pending">Pending</option></select><div id="taskList"></div></div><div id="globalTasksContainer"></div>';
const { getJson } = require(base + 'apiService.js');
getJson.mockImplementation(async url => {
if (url === '/api/tasks/%23V%23exact_task') return { task_concept_id: '#V#exact_task', title: 'Exact completed task', status: 'completed' };
if (url.includes('limit=50')) return { tasks: [{ task_concept_id: '#V#other', title: 'Other task', status: 'pending' }] };
return {};
});
const panel = require(base + 'components/taskPanel.js');
await panel.openTaskInPanel('#V#exact_task');
document.getElementById('taskStatusFilter').dispatchEvent(new Event('change'));
document.querySelector('.task-open-in-tab-btn').click();
expect(document.getElementById('taskPanel').classList.contains('hidden')).toBe(true);
expect(require(base + 'tabNavigation.js').activateTab).toHaveBeenCalledWith('globalTasksTab');
await panel.showGlobalTasks();
expect(document.querySelector('#globalTaskInspector').textContent).toContain('Exact completed task');
expect(document.querySelector('.task-item.is-selected').dataset.taskId).toBe('#V#exact_task');
expect(document.getElementById('globalTaskStatusFilter').value).toBe('pending');
expect(document.activeElement.id).toBe('globalTaskInspector');
});
Loading