diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index dcfa907c..360d185e 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -45101,7 +45101,7 @@ def _build_default_catalogue_task_and_workflow_definitions() -> List[MethodDefin "List Von tasks. Filter by user (assignee), status, priority, task category, " "task source, or organisation. " "If user_concept_id is provided, returns tasks assigned to that user. " - "Valid statuses: pending, in_progress, completed, cancelled, blocked." + "Valid statuses: pending, in_progress, completed, deployed, cancelled, blocked." ), ), MethodDefinition( diff --git a/src/backend/server/routes/task_routes.py b/src/backend/server/routes/task_routes.py index bad0e817..d149cf28 100644 --- a/src/backend/server/routes/task_routes.py +++ b/src/backend/server/routes/task_routes.py @@ -479,7 +479,7 @@ def update_task_route(task_concept_id: str) -> ResponseReturnValue: """Update a task. Request body (all fields optional): - status: str (pending, in_progress, completed, cancelled, blocked) + status: str (pending, in_progress, completed, deployed, cancelled, blocked) assignee_concept_id: str title: str description: str diff --git a/src/backend/services/task_management_service.py b/src/backend/services/task_management_service.py index 543f7e0e..6633bc85 100644 --- a/src/backend/services/task_management_service.py +++ b/src/backend/services/task_management_service.py @@ -224,6 +224,8 @@ def finish(**metadata: Any) -> dict[str, Any]: TASK_STATUS_PENDING = "pending" TASK_STATUS_IN_PROGRESS = "in_progress" TASK_STATUS_COMPLETED = "completed" +# Finished work is completed; deployed additionally records runtime delivery. +TASK_STATUS_DEPLOYED = "deployed" TASK_STATUS_CANCELLED = "cancelled" TASK_STATUS_BLOCKED = "blocked" @@ -231,6 +233,7 @@ def finish(**metadata: Any) -> dict[str, Any]: TASK_STATUS_PENDING, TASK_STATUS_IN_PROGRESS, TASK_STATUS_COMPLETED, + TASK_STATUS_DEPLOYED, TASK_STATUS_CANCELLED, TASK_STATUS_BLOCKED, } @@ -302,7 +305,29 @@ def finish(**metadata: Any) -> dict[str, Any]: "to_status": TASK_STATUS_CANCELLED, }, ], + TASK_STATUS_DEPLOYED: [ + { + "transition_id": "mark_completed", + "name": "Return to completed", + "to_status": TASK_STATUS_COMPLETED, + }, + { + "transition_id": "reopen_pending", + "name": "Reopen to pending", + "to_status": TASK_STATUS_PENDING, + }, + { + "transition_id": "reopen_in_progress", + "name": "Reopen to in progress", + "to_status": TASK_STATUS_IN_PROGRESS, + }, + ], TASK_STATUS_COMPLETED: [ + { + "transition_id": "deploy", + "name": "Mark deployed", + "to_status": TASK_STATUS_DEPLOYED, + }, { "transition_id": "reopen_pending", "name": "Reopen to pending", @@ -2217,7 +2242,7 @@ def update_task_status( Args: task_concept_id: The task's concept_id - status: New status (pending, in_progress, completed, cancelled, blocked) + status: New status (pending, in_progress, completed, deployed, cancelled, blocked) actor_concept_id: Trusted actor performing the transition, when available Returns: @@ -5859,6 +5884,7 @@ def get_task_taxonomy() -> Dict[str, Any]: "TASK_STATUS_PENDING", "TASK_STATUS_IN_PROGRESS", "TASK_STATUS_COMPLETED", + "TASK_STATUS_DEPLOYED", "TASK_STATUS_CANCELLED", "TASK_STATUS_BLOCKED", "VALID_TASK_STATUSES", 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 03aa87d0..0a04a639 100644 --- a/src/frontend/web/von_interface/static/js/components/taskPanel.js +++ b/src/frontend/web/von_interface/static/js/components/taskPanel.js @@ -30,6 +30,7 @@ let _filterTaskSourceId = 'all'; let _isGlobalTabMode = false; // True when rendering into global tasks tab let _taskDetailState = {}; // taskId -> detail panel state let _selectedTaskId = ''; +let _selectionCleared = false; let _pendingTaskNavigation = null; let _referencedGlobalTaskId = ''; let _panelReturnFocus = null; @@ -86,7 +87,8 @@ const TASK_EXTERNAL_RESOURCE_ACTION_HREF_PATTERN = /^\/api\/tasks\/[A-Za-z0-9%_- const TASK_STATUS_OPTIONS = [ { value: 'pending', label: 'Pending', icon: '⏳' }, { value: 'in_progress', label: 'In Progress', icon: '🔄' }, - { value: 'completed', label: 'Completed', icon: '✅' }, + { value: 'completed', label: 'Completed', icon: '✅', description: 'Work finished; not necessarily deployed' }, + { value: 'deployed', label: 'Deployed', icon: '🚀', description: 'Work deployed to its target runtime' }, { value: 'cancelled', label: 'Cancelled', icon: '❌' }, { value: 'blocked', label: 'Blocked', icon: '🚫' }, ]; @@ -1024,6 +1026,7 @@ function renderGlobalTasksTabContent() { + @@ -1292,7 +1295,7 @@ export async function setCurrentSession(sessionId) { * Get the current task count for badge display. */ export function getTaskCount() { - return _tasks.filter(t => t.status !== 'completed' && t.status !== 'cancelled').length; + return _tasks.filter(t => !['completed', 'deployed', 'cancelled'].includes(t.status)).length; } /** @@ -1575,6 +1578,7 @@ function getFilteredTasks() { } function ensureSelectedTaskStillValid(filteredTasks = null) { + if (_selectionCleared) return; const visibleTasks = Array.isArray(filteredTasks) ? filteredTasks : getFilteredTasks(); if (visibleTasks.length === 0) { _selectedTaskId = ''; @@ -1590,7 +1594,7 @@ function renderGlobalTaskSummary(filteredTasks = getFilteredTasks()) { const summaryEl = _globalTasksContainer?.querySelector('#globalTaskSummary'); if (!summaryEl) return; - const activeCount = filteredTasks.filter((task) => !['completed', 'cancelled'].includes(task.status)).length; + const activeCount = filteredTasks.filter((task) => !['completed', 'deployed', 'cancelled'].includes(task.status)).length; const sourcedCount = filteredTasks.filter((task) => task.task_source_id).length; const typedCount = filteredTasks.filter((task) => Array.isArray(task.task_type_ids) && task.task_type_ids.length > 0).length; const selectedTask = filteredTasks.find((task) => getTaskId(task) === _selectedTaskId) || null; @@ -1916,7 +1920,7 @@ function renderTaskList() { html += ''; } else { const grouped = groupTasksByStatus(filteredTasks); - const boardStatuses = ['in_progress', 'pending', 'blocked', 'completed', 'cancelled']; + const boardStatuses = ['in_progress', 'pending', 'blocked', 'completed', 'deployed', 'cancelled']; html = '
${escapeHtml(detailTask.description || 'No description yet.')}
${truncatedDescription}
${typeChipsHtml} ${organisationChipHtml} + ${renderTaskDelegation(task)} ${bulkCollectionChipsHtml} ${labelsHtml} ${componentsHtml} diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index b044815d..7dfa2ecb 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -3687,7 +3687,7 @@ button.unified-search-conversation-main { .task-board-view { display: grid; - grid-template-columns: repeat(5, minmax(240px, 1fr)); + grid-template-columns: repeat(6, minmax(240px, 1fr)); gap: 16px; align-items: start; min-width: 1280px; diff --git a/tests/backend/test_task_status_reentry.py b/tests/backend/test_task_status_reentry.py index 0e3dae87..b394dadd 100644 --- a/tests/backend/test_task_status_reentry.py +++ b/tests/backend/test_task_status_reentry.py @@ -34,12 +34,12 @@ def singleton(**kwargs): monkeypatch.setattr(tasks, "_append_task_history_event", history) monkeypatch.setattr(tasks, "maybe_launch_task_status_workflow", launches) - for status in ("in_progress", "blocked", "in_progress"): + for status in ("in_progress", "blocked", "deployed", "in_progress"): assert tasks.update_task_status(doc["concept_id"], status)["status"] == status assert values == [status] - assert history.call_count == launches.call_count == 3 + assert history.call_count == launches.call_count == 4 tasks.update_task_status(doc["concept_id"], "in_progress") - assert history.call_count == launches.call_count == 3 + assert history.call_count == launches.call_count == 4 def test_failed_status_readback_does_not_emit_completion(monkeypatch): @@ -57,3 +57,17 @@ def test_failed_status_readback_does_not_emit_completion(monkeypatch): tasks.update_task_status(doc["concept_id"], "completed") repository.update_one.assert_not_called() completion.assert_not_called() + + +def test_deployed_transition_and_reopening(monkeypatch): + current = {"task_concept_id": "#V#task_deploy", "status": "completed"} + monkeypatch.setattr(tasks, "get_task", lambda _: current) + update = MagicMock(return_value={**current, "status": "deployed"}) + monkeypatch.setattr(tasks, "update_task_status", update) + monkeypatch.setattr(tasks, "_append_task_history_event", MagicMock()) + tasks.transition_task(current["task_concept_id"], transition_id="deploy") + assert update.call_args.args[:2] == (current["task_concept_id"], "deployed") + current["status"] = "deployed" + destinations = {item["to_status"] for item in tasks.get_task_transitions(current["task_concept_id"])["transitions"]} + assert destinations == {"completed", "pending", "in_progress"} + assert "deployed" in tasks.VALID_TASK_STATUSES diff --git a/tests/browser/taskBoardStatusSelection.cjs b/tests/browser/taskBoardStatusSelection.cjs new file mode 100644 index 00000000..feaa44cf --- /dev/null +++ b/tests/browser/taskBoardStatusSelection.cjs @@ -0,0 +1,62 @@ +/** Actual task-panel module and CSS with isolated API fixtures; no live data or models. */ +const { chromium } = require('playwright'); +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); + +(async () => { + const output = path.resolve(process.argv[2] || '.run/task-board-status-selection'); + fs.mkdirSync(output, { recursive: true }); + const root = path.resolve('src/frontend/web/von_interface/static'); + const task = { task_concept_id: '#V#task_fixture', title: 'Delivered DGX task', status: 'deployed', priority: 'medium', assignee_concept_id: '#V#codex_dgx' }; + const modules = { + '/js/apiService.js': `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)=>{if(url.startsWith('/api/tasks/?'))return {tasks:[${JSON.stringify(task)}]}; if(url==='/api/tasks/%23V%23task_fixture')return ${JSON.stringify(task)};return {};}; export const postJson=async()=>({});export const patchJson=async()=>({});export const deleteJson=async()=>({});`, + '/js/tabNavigation.js': 'export const activateTab=()=>{};', + '/js/utils/toast.js': 'export const showToast=()=>{};', + '/js/markdownUtils.js': 'export const renderMarkdownViaServer=async()=>"";', + '/js/utils/nameSelection.js': 'export const selectBestNameForContext=()=>null;export const selectShortestNameForContext=()=>null;', + '/js/components/taskRunActivity.js': 'export const openTaskRunActivity=()=>{};', + }; + const server = http.createServer((req, res) => { + if (req.url === '/') { + res.setHeader('Content-Type', 'text/html'); + res.end(''); + } else if (modules[req.url]) { + res.setHeader('Content-Type', 'text/javascript');res.end(modules[req.url]); + } else if (['/styles.css', '/js/components/taskPanel.js'].includes(req.url)) { + res.setHeader('Content-Type', req.url.endsWith('.css') ? 'text/css' : 'text/javascript');res.end(fs.readFileSync(path.join(root, req.url))); + } else {res.statusCode=404;res.end();} + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + let browser; + try { + browser = await chromium.launch({headless:true, args:['--no-sandbox']}); + const page = await browser.newPage(); + const errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.locator('.task-item').waitFor(); + for (const width of [1440, 390]) { + await page.setViewportSize({width,height:900}); + assert.equal(await page.locator('.task-group').count(),6); + assert.match(await page.locator('.task-item').innerText(),/Delegated to Codex DGX/); + await page.getByRole('button',{name:'Close task details',exact:true}).click(); + assert.equal(await page.locator('.task-item.is-selected').count(),0); + assert.equal(await page.locator('.task-item').evaluate(el=>el===document.activeElement),true); + await page.keyboard.press('Enter'); + await page.getByRole('button',{name:'Close task details',exact:true}).waitFor(); + await page.getByRole('button',{name:'Close task details',exact:true}).focus(); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('.task-item.is-selected').count(),0); + await page.selectOption('#globalTaskStatusFilter','pending'); + assert.equal(await page.locator('.task-item').count(),0); + await page.selectOption('#globalTaskStatusFilter','deployed'); + assert.equal(await page.locator('.task-item').count(),1); + await page.locator('.task-item').focus();await page.keyboard.press('Enter'); + await page.screenshot({path:path.join(output,`board-${width}.png`),fullPage:true}); + } + assert.deepEqual(errors,[]); + fs.writeFileSync(path.join(output,'receipt.json'),JSON.stringify({passed:true,widths:[1440,390],checks:['six status lanes','DGX delegation','clear and keyboard reopen','Escape and focus restoration','deployed filtering'],scope:'isolated API fixture using candidate taskPanel.js and styles.css'},null,2)); + console.log('Task board browser fixture passed'); + } finally {if(browser)await browser.close();await new Promise(resolve=>server.close(resolve));} +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/tests/frontend/taskPanelLayout.test.js b/tests/frontend/taskPanelLayout.test.js index 1e5dadb8..a25e2a75 100644 --- a/tests/frontend/taskPanelLayout.test.js +++ b/tests/frontend/taskPanelLayout.test.js @@ -49,6 +49,7 @@ function buildTasks() { title: 'Pending layout check', description: 'A task with ordinary content.', status: 'pending', + assignee_concept_id: '#V#codex_dgx', priority: 'medium', task_type_ids: ['#V#one_off_task_specification'], task_source_id: '#V#von_native_task_source', @@ -124,7 +125,7 @@ describe('global task board layout', () => { expect(taskList.getAttribute('aria-label')).toBe('Task board'); expect(taskList.getAttribute('aria-describedby')).toBe('globalTaskBoardScrollHint'); expect(scrollHint.classList.contains('hidden')).toBe(false); - expect(document.querySelectorAll('.task-board-view .task-group')).toHaveLength(5); + expect(document.querySelectorAll('.task-board-view .task-group')).toHaveLength(6); taskList.scrollLeft = 320; taskList.scrollTop = 180; @@ -133,6 +134,60 @@ describe('global task board layout', () => { expect(taskList.scrollTop).toBe(180); }); + test('labels delegated work and clears selection with focus restoration, including Escape', async () => { + const { showGlobalTasks } = require(taskPanelModulePath); + await showGlobalTasks(); + await flushRenderQueue(); + const card = () => document.querySelector('.task-item[data-task-id="#V#task_layout_pending"]'); + expect(card().textContent).toContain('Delegated to Codex DGX'); + expect(document.querySelectorAll('.task-item .task-delegation-chip')).toHaveLength(1); + card().click(); + await flushRenderQueue(); + expect(document.querySelector('#globalTaskInspector').textContent).toContain('Delegated to Codex DGX'); + document.querySelector('.task-clear-selection-btn').click(); + expect(document.querySelector('#globalTaskInspector').textContent).toContain('Select a task'); + expect(document.querySelector('.task-item.is-selected')).toBeNull(); + expect(document.activeElement).toBe(card()); + const viewMode = document.querySelector('#globalTaskViewMode'); + viewMode.value = 'list'; + viewMode.dispatchEvent(new Event('change', { bubbles: true })); + expect(document.querySelector('.task-item.is-selected')).toBeNull(); + card().click(); + await flushRenderQueue(); + const close = document.querySelector('.task-clear-selection-btn'); + close.focus(); + close.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(document.querySelector('.task-item.is-selected')).toBeNull(); + expect(document.activeElement).toBe(card()); + }); + + test('renders and filters deployed work without counting it as active or overdue', async () => { + const { getJson } = require(apiServiceModulePath); + const original = getJson.getMockImplementation(); + getJson.mockImplementation((url) => { + if (typeof url === 'string' && url.startsWith('/api/tasks/?')) { + return Promise.resolve({ tasks: [{ ...buildTasks()[0], status: 'deployed', due_date: '2020-01-01' }] }); + } + return original(url); + }); + const { showGlobalTasks } = require(taskPanelModulePath); + await showGlobalTasks(); + await flushRenderQueue(); + const card = document.querySelector('.task-item'); + expect(card.querySelector('.task-status-badge').textContent).toContain('Deployed'); + expect(card.querySelector('.overdue')).toBeNull(); + expect(document.querySelector('#globalTaskSummary').textContent).toMatch(/0 active/); + expect(card.querySelector('option[value="deployed"]').selected).toBe(true); + expect(document.querySelectorAll('option[value="deployed"]').length).toBeGreaterThan(1); + const filter = document.querySelector('#globalTaskStatusFilter'); + filter.value = 'pending'; + filter.dispatchEvent(new Event('change', { bubbles: true })); + expect(document.querySelector('.task-item')).toBeNull(); + filter.value = 'deployed'; + filter.dispatchEvent(new Event('change', { bubbles: true })); + expect(document.querySelectorAll('.task-item')).toHaveLength(1); + }); + test('removes the nested scrolling affordance in list mode', async () => { const { showGlobalTasks } = require(taskPanelModulePath); await showGlobalTasks();