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
2 changes: 1 addition & 1 deletion src/backend/integrations/internal_mcp/catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/backend/server/routes/task_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion src/backend/services/task_management_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,16 @@ 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"

VALID_TASK_STATUSES = {
TASK_STATUS_PENDING,
TASK_STATUS_IN_PROGRESS,
TASK_STATUS_COMPLETED,
TASK_STATUS_DEPLOYED,
TASK_STATUS_CANCELLED,
TASK_STATUS_BLOCKED,
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
49 changes: 40 additions & 9 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,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;
Expand Down Expand Up @@ -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: '🚫' },
];
Expand Down Expand Up @@ -1024,6 +1026,7 @@ function renderGlobalTasksTabContent() {
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
<option value="deployed">Deployed</option>
<option value="cancelled">Cancelled</option>
<option value="blocked">Blocked</option>
</select>
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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 = '';
Expand All @@ -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;
Expand Down Expand Up @@ -1916,7 +1920,7 @@ function renderTaskList() {
html += '</div>';
} else {
const grouped = groupTasksByStatus(filteredTasks);
const boardStatuses = ['in_progress', 'pending', 'blocked', 'completed', 'cancelled'];
const boardStatuses = ['in_progress', 'pending', 'blocked', 'completed', 'deployed', 'cancelled'];
html = '<div class="task-board-view">';
boardStatuses.forEach((status) => {
html += renderTaskGroup(status, grouped[status] || []);
Expand Down Expand Up @@ -1960,7 +1964,7 @@ function renderTaskGroup(status, tasks) {
<div class="task-group" data-status="${status}">
<div class="task-group-header">
<span class="task-group-icon">${statusInfo.icon}</span>
<span class="task-group-label">${statusInfo.label}</span>
<span class="task-group-label" title="${escapeHtml(statusInfo.description || statusInfo.label)}">${statusInfo.label}</span>
<span class="task-group-count">(${tasks.length})</span>
</div>
<div class="task-group-body">
Expand Down Expand Up @@ -2834,6 +2838,7 @@ function renderTaskDetailsPanel(task, detailState) {

return `
<div class="task-detail-panel" data-task-id="${escapeHtml(taskId)}">
${renderTaskDelegation(detailTask)}
${renderTaskWorkProduct(detailTask, detailState)}
<div class="task-detail-section">
<h4>Editable task context</h4>
Expand Down Expand Up @@ -3113,7 +3118,8 @@ function renderTaskInspector(task, detailState) {
<p>${escapeHtml(detailTask.description || 'No description yet.')}</p>
</div>
<div class="task-inspector-badges">
<span class="task-status-badge">${escapeHtml(getStatusInfo(detailTask.status).label)}</span>
${renderTaskDelegation(detailTask)}
<span class="task-status-badge" title="${escapeHtml(getStatusInfo(detailTask.status).description || getStatusInfo(detailTask.status).label)}">${escapeHtml(getStatusInfo(detailTask.status).label)}</span>
<span class="task-priority-chip">${escapeHtml(getPriorityInfo(detailTask.priority).label)}</span>
${renderTaskExecuteWithVonButton(detailTask, 'task-inspector-execute-btn')}
${renderTaskDiscussButton(detailTask, 'task-inspector-discuss-btn')}
Expand Down Expand Up @@ -3324,11 +3330,35 @@ function renderGlobalTaskInspector() {
}

const detailState = getTaskDetailState(_selectedTaskId);
inspectorEl.innerHTML = renderTaskInspector(selectedTask, detailState);
inspectorEl.innerHTML = `<button type="button" class="task-clear-selection-btn" aria-label="Close task details">Close task details ×</button>${renderTaskInspector(selectedTask, detailState)}`;
inspectorEl.querySelector('.task-clear-selection-btn').addEventListener('click', clearTaskSelection);
_globalTasksContainer.onkeydown = (event) => {
if (!_selectedTaskId || event.key !== 'Escape' || event.defaultPrevented
|| event.target.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]')) return;
event.preventDefault();
clearTaskSelection();
};
}

function clearTaskSelection() {
const previousId = _selectedTaskId;
_selectionCleared = true;
_selectedTaskId = '';
renderTaskList();
const previousCard = [...(_taskListEl?.querySelectorAll('.task-item') || [])]
.find((item) => item.dataset.taskId === previousId);
(previousCard || _taskListEl)?.focus({ preventScroll: true });
}

function renderTaskDelegation(task) {
return task?.assignee_concept_id === '#V#codex_dgx'
? '<span class="task-source-chip task-delegation-chip" title="Assigned to Codex DGX; execution has not necessarily started">Delegated to Codex DGX</span>'
: '';
}

async function selectTask(taskId, { loadDetails = true } = {}) {
if (!taskId) return;
_selectionCleared = false;
_selectedTaskId = taskId;
renderTaskList();
if (!loadDetails) return;
Expand Down Expand Up @@ -3383,7 +3413,7 @@ function renderTaskItem(task) {
let dueDateHtml = '';
if (task.due_date) {
const dueDate = new Date(task.due_date);
const isOverdue = dueDate < new Date() && task.status !== 'completed' && task.status !== 'cancelled';
const isOverdue = dueDate < new Date() && !['completed', 'deployed', 'cancelled'].includes(task.status);
dueDateHtml = `
<span class="task-due-date ${isOverdue ? 'overdue' : ''}" title="Due date">
📅 ${dueDate.toLocaleDateString()}
Expand Down Expand Up @@ -3451,12 +3481,13 @@ function renderTaskItem(task) {
<span class="task-priority" title="Priority: ${priorityInfo.label}">${priorityInfo.icon}</span>
${referenceCodeHtml}
${titleHtml}
<span class="task-status-badge" title="Status">${statusInfo.icon} ${escapeHtml(statusInfo.label)}</span>
<span class="task-status-badge" title="${escapeHtml(statusInfo.description || statusInfo.label)}">${statusInfo.icon} ${escapeHtml(statusInfo.label)}</span>
</div>
<div class="task-item-body">
<p class="task-description">${truncatedDescription}</p>
${typeChipsHtml}
${organisationChipHtml}
${renderTaskDelegation(task)}
${bulkCollectionChipsHtml}
${labelsHtml}
${componentsHtml}
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 17 additions & 3 deletions tests/backend/test_task_status_reentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
62 changes: 62 additions & 0 deletions tests/browser/taskBoardStatusSelection.cjs
Original file line number Diff line number Diff line change
@@ -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('<!doctype html><html><head><link rel="stylesheet" href="/styles.css"></head><body><div id="globalTasksContainer"></div><script type="module">import {showGlobalTasks} from "/js/components/taskPanel.js";await showGlobalTasks();</script></body></html>');
} 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;});
Loading
Loading