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
193 changes: 183 additions & 10 deletions src/frontend/web/von_interface/static/js/components/taskPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import { renderMarkdownViaServer } from '../markdownUtils.js';
import { selectBestNameForContext, selectShortestNameForContext } from '../utils/nameSelection.js';

// Task panel state
const _taskTabs = new Map();
let _taskTabStackExpanded = false;
let _taskTabCounter = 0;
let _taskTabStackEl = null;
let _taskTabRefreshSequence = 0;
let _panelEl = null;
let _taskListEl = null;
let _globalTasksContainer = null; // Container for global tasks tab
Expand Down Expand Up @@ -185,6 +190,9 @@ async function ensureTaskOrganisationOptionsLoaded() {
}

function resetTaskPanelScopedState(activeOrganisationConceptId, { actorChanged = false } = {}) {
const hadActiveTaskTab = [..._taskTabs.values()].some(tab => tab.content.classList.contains('active'));
for (const taskId of [..._taskTabs.keys()]) closeTaskTab(taskId, { activateFallback: false });
if (hadActiveTaskTab) activateTab('chatTab');
_panelTaskIds = null;
_panelLoadGeneration += 1;
_taskQueueActivityGeneration += 1;
Expand Down Expand Up @@ -737,6 +745,7 @@ function formatDateTimeOrDash(value) {
}

function getTaskDetailState(taskId) {
if (_taskTabs.has(taskId)) return _taskTabs.get(taskId).detailState;
if (!_taskDetailState[taskId]) {
_taskDetailState[taskId] = {
expanded: false,
Expand Down Expand Up @@ -2845,6 +2854,7 @@ async function openTaskWorkProduct(taskId) {
detailState.productError = '';
detailState.productHtml = '';
renderTaskList();
renderTaskTab(taskId);
try {
const product = await getJson(`/api/tasks/${encodeURIComponent(taskId)}/work-product`);
if (generation !== _taskQueueActivityGeneration) return;
Expand All @@ -2859,7 +2869,10 @@ async function openTaskWorkProduct(taskId) {
detailState.productError = 'Could not open the current product. Try again.';
} finally {
detailState.productLoading = false;
if (generation === _taskQueueActivityGeneration) renderTaskList();
if (generation === _taskQueueActivityGeneration) {
renderTaskList();
renderTaskTab(taskId);
}
}
}

Expand Down Expand Up @@ -3141,7 +3154,7 @@ function renderTaskExternalResourceActions(task) {
`;
}

function renderTaskInspector(task, detailState) {
function renderTaskInspector(task, detailState, { inTaskTab = false } = {}) {
const detailTask = detailState?.task || task;
const taskId = getTaskId(detailTask);
const sourceSummary = getTaskSourceSummary(detailTask);
Expand Down Expand Up @@ -3187,6 +3200,7 @@ function renderTaskInspector(task, detailState) {
<span class="task-priority-chip">${escapeHtml(getPriorityInfo(detailTask.priority).label)}</span>
${renderTaskExecuteWithVonButton(detailTask, 'task-inspector-execute-btn')}
${renderTaskDiscussButton(detailTask, 'task-inspector-discuss-btn')}
${!inTaskTab ? renderOpenTaskTabButton(taskId) : ''}
</div>
</div>

Expand Down Expand Up @@ -3368,6 +3382,143 @@ function renderTaskInspector(task, detailState) {
`;
}

function renderOpenTaskTabButton(taskId) {
return `<button type="button" class="task-open-in-tab-btn btn-mini" data-task-id="${escapeHtml(taskId)}">Open in Tab</button>`;
}

// Reuse the concept stack presentation, with independent task contents and recency.
function rebuildTaskTabStack() {
if (!_taskTabs.size) {
_taskTabStackEl?.remove();
_taskTabStackEl = null;
_taskTabStackExpanded = false;
} else {
if (!_taskTabStackEl) {
_taskTabStackEl = document.createElement('div');
_taskTabStackEl.id = 'taskTabStack';
_taskTabStackEl.className = 'concept-tab-bucket task-tab-stack';
_taskTabStackEl.setAttribute('role', 'group');
_taskTabStackEl.setAttribute('aria-label', 'Open task tabs');
const container = document.getElementById('tabContainer');
const anchor = container.querySelector('[data-tab="globalTasksTab"]');
container.insertBefore(_taskTabStackEl, anchor?.parentElement === container ? anchor : null);
}
const entries = [..._taskTabs.values()].reverse();
const primary = document.createElement('div');
primary.className = 'concept-tab-bucket-primary';
primary.appendChild(entries[0].button);
const overflow = document.createElement('div');
overflow.id = 'taskTabStackOverflow';
overflow.className = 'concept-tab-bucket-panel';
overflow.hidden = !_taskTabStackExpanded;
if (entries.length > 1) {
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'concept-tab-bucket-toggle';
toggle.textContent = `+${entries.length - 1}`;
toggle.setAttribute('aria-label', `${_taskTabStackExpanded ? 'Hide' : 'Show'} ${entries.length - 1} more task tabs`);
toggle.setAttribute('aria-expanded', String(_taskTabStackExpanded));
toggle.setAttribute('aria-controls', overflow.id);
toggle.onclick = () => {
_taskTabStackExpanded = !_taskTabStackExpanded;
rebuildTaskTabStack();
_taskTabStackEl.querySelector('.concept-tab-bucket-toggle')?.focus();
};
primary.appendChild(toggle);
entries.slice(1).forEach(entry => overflow.appendChild(entry.button));
}
_taskTabStackEl.classList.toggle('is-expanded', _taskTabStackExpanded);
_taskTabStackEl.replaceChildren(primary, overflow);
}
document.dispatchEvent(new CustomEvent('von:tab-strip-layout-changed'));
}

function activateTaskTab(taskId) {
const tab = _taskTabs.get(taskId);
if (!tab) return;
_taskTabs.delete(taskId);
_taskTabs.set(taskId, tab);
_taskTabStackExpanded = false;
rebuildTaskTabStack();
activateTab(tab.content.id);
tab.button.querySelector('.task-tab-open').focus();
}

function closeTaskTab(taskId, { activateFallback = true } = {}) {
const tab = _taskTabs.get(taskId);
if (!tab) return;
const wasActive = tab.content.classList.contains('active');
tab.button.remove();
tab.content.remove();
_taskTabs.delete(taskId);
rebuildTaskTabStack();
if (wasActive && activateFallback) {
const remaining = [..._taskTabs.keys()].pop();
if (remaining) activateTaskTab(remaining);
else {
activateTab('globalTasksTab');
void showGlobalTasks();
document.querySelector('[data-tab="globalTasksTab"]')?.focus();
}
}
}

function renderTaskTab(taskId) {
const tab = _taskTabs.get(taskId);
if (!tab) return;
const task = tab.detailState.task || tab.task;
const title = task.title || 'Untitled task';
tab.button.querySelector('.task-tab-open').textContent = `📋 ${title}`;
tab.button.title = title;
tab.button.querySelector('.close-tab').setAttribute('aria-label', `Close task tab: ${title}`);
tab.content.innerHTML = `<button type="button" class="task-tab-refresh btn-mini">Refresh task</button>${renderTaskInspector(task, tab.detailState, { inTaskTab: true })}`;
tab.content.querySelector('.task-tab-refresh').onclick = () => void loadTaskDetails(taskId);
attachTaskEventListeners([tab.content]);
}

export async function openTaskInTab(rawTaskId) {
const taskId = normaliseTaskConceptId(rawTaskId);
if (!taskId) return;
const container = document.getElementById('tabContainer');
const area = document.querySelector('.tab-content-area');
if (!container || !area) {
showToast('Task tabs are unavailable in this view.', 'error');
return;
}
ensureTaskPanelOrganisationSwitchListener();
ensureTaskPanelAuthStatusListener();
hideTaskPanel({ restoreFocus: false });
if (_taskTabs.has(taskId)) {
activateTaskTab(taskId);
return;
}
const task = _tasks.find(item => getTaskId(item) === taskId) || { task_concept_id: taskId };
const detailState = getTaskDetailState(taskId);
const content = document.createElement('div');
content.id = `taskTab_${++_taskTabCounter}`;
content.className = 'tab-content task-tab-content';
content.dataset.taskId = taskId;
const button = document.createElement('div');
button.className = 'tab-button closable task-tab-button';
button.dataset.tab = content.id;
button.dataset.taskId = taskId;
const open = document.createElement('button');
open.type = 'button';
open.className = 'task-tab-open tab-button-label';
open.onclick = () => activateTaskTab(taskId);
const close = document.createElement('button');
close.type = 'button';
close.className = 'close-tab';
close.textContent = '×';
close.onclick = (event) => { event.stopPropagation(); closeTaskTab(taskId); };
button.append(open, close);
area.appendChild(content);
_taskTabs.set(taskId, { task, detailState, button, content });
renderTaskTab(taskId);
activateTaskTab(taskId);
await loadTaskDetails(taskId);
}

function renderGlobalTaskInspector() {
const inspectorEl = _globalTasksContainer?.querySelector('#globalTaskInspector');
if (!inspectorEl || !_isGlobalTabMode) return;
Expand Down Expand Up @@ -3574,7 +3725,8 @@ function renderTaskItem(task) {
<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>` : ''}
${renderOpenTaskTabButton(taskId)}
${!_isGlobalTabMode ? `<button type="button" class="task-open-in-tasks-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 @@ -3606,6 +3758,8 @@ function escapeHtml(str) {
function replaceCachedTask(updatedTask) {
const updatedTaskId = getTaskId(updatedTask);
if (!updatedTaskId) return;
const tab = _taskTabs.get(updatedTaskId);
if (tab) tab.task = updatedTask;
const existingIndex = _tasks.findIndex((item) => getTaskId(item) === updatedTaskId);
if (existingIndex >= 0) {
_tasks[existingIndex] = updatedTask;
Expand All @@ -3616,9 +3770,12 @@ function replaceCachedTask(updatedTask) {
async function loadTaskDetails(taskId, { refreshList = false } = {}) {
const detailState = getTaskDetailState(taskId);
const scopeGenerationAtStart = _taskQueueActivityGeneration;
const requestSequence = ++_taskTabRefreshSequence;
detailState.requestSequence = requestSequence;
detailState.loading = true;
detailState.error = null;
renderTaskList();
renderTaskTab(taskId);

try {
const [task, commentsResult, attachmentsResult, historyResult, worklogResult] = await Promise.all([
Expand All @@ -3628,7 +3785,7 @@ async function loadTaskDetails(taskId, { refreshList = false } = {}) {
getJson(`/api/tasks/${encodeURIComponent(taskId)}/history?limit=200`),
getJson(`/api/tasks/${encodeURIComponent(taskId)}/worklog?limit=200`),
]);
if (scopeGenerationAtStart !== _taskQueueActivityGeneration) return;
if (scopeGenerationAtStart !== _taskQueueActivityGeneration || detailState.requestSequence !== requestSequence) return;
detailState.task = task;
detailState.comments = commentsResult.comments || [];
detailState.attachments = attachmentsResult.attachments || [];
Expand All @@ -3640,10 +3797,15 @@ async function loadTaskDetails(taskId, { refreshList = false } = {}) {
}
} catch (err) {
console.error('[taskPanel] Failed to load task details:', err);
detailState.error = 'Failed to load task details';
if (scopeGenerationAtStart === _taskQueueActivityGeneration && detailState.requestSequence === requestSequence) {
detailState.error = 'Failed to load task details';
}
} finally {
detailState.loading = false;
renderTaskList();
if (scopeGenerationAtStart === _taskQueueActivityGeneration && detailState.requestSequence === requestSequence) {
detailState.loading = false;
renderTaskList();
renderTaskTab(taskId);
}
}
}

Expand Down Expand Up @@ -3687,7 +3849,7 @@ async function saveTaskParityFields(taskId, panelEl) {
setIfPresent('.task-detail-due-input', 'due_date', localInputValueToIso);

const previousOrganisationId = normaliseTaskConceptId(
(_tasks.find((task) => getTaskId(task) === taskId)?.organisation_concept_id) || '',
(getTaskDetailState(taskId).task?.organisation_concept_id || _tasks.find((task) => getTaskId(task) === taskId)?.organisation_concept_id) || '',
);
const organisationInput = panelEl.querySelector('.task-detail-organisation-input');
const requestedOrganisationId = normaliseTaskConceptId(
Expand All @@ -3699,6 +3861,7 @@ async function saveTaskParityFields(taskId, panelEl) {
await patchJson(`/api/tasks/${encodeURIComponent(taskId)}`, payload);
showToast('Task fields updated', 'success');
if (Object.prototype.hasOwnProperty.call(payload, 'organisation_concept_id')) {
closeTaskTab(taskId);
if (_isGlobalTabMode) {
_selectedTaskId = '';
delete _taskDetailState[taskId];
Expand Down Expand Up @@ -3813,8 +3976,8 @@ async function openTaskConversation(sessionId) {
/**
* Attach event listeners to task items.
*/
function attachTaskEventListeners() {
const roots = [
function attachTaskEventListeners(explicitRoots = null) {
const roots = explicitRoots || [
_taskListEl,
_globalTasksContainer?.querySelector('#globalTaskInspector'),
].filter(Boolean);
Expand Down Expand Up @@ -3885,6 +4048,14 @@ function attachTaskEventListeners() {
});

root.querySelectorAll('.task-open-in-tab-btn').forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
void openTaskInTab(button.dataset.taskId);
});
});

root.querySelectorAll('.task-open-in-tasks-btn').forEach((button) => {
button.addEventListener('click', (event) => {
event.stopPropagation();
const task = _tasks.find(item => getTaskId(item) === button.dataset.taskId);
Expand Down Expand Up @@ -4155,6 +4326,7 @@ async function updateTaskStatus(taskId, newStatus) {
await patchJson(`/api/tasks/${encodeURIComponent(taskId)}`, { status: newStatus });
showToast('Task updated', 'success');
await refreshTasks();
if (_taskTabs.has(taskId)) await loadTaskDetails(taskId);
} catch (err) {
console.error('[taskPanel] Failed to update task:', err);
showToast('Failed to update task', 'error');
Expand All @@ -4169,6 +4341,7 @@ async function deleteTask(taskId) {
await deleteJson(`/api/tasks/${encodeURIComponent(taskId)}`);
showToast('Task deleted', 'success');

closeTaskTab(taskId);
// Remove from local cache and re-render
_tasks = _tasks.filter(t => getTaskId(t) !== taskId);
refreshTaskGroupOptions();
Expand Down
35 changes: 35 additions & 0 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -19147,3 +19147,38 @@ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-bu
.message-draft-shell .prompt-input { width: 100%; box-sizing: border-box; }
.message-draft-shell [data-draft-recovery][hidden] { display: none; }
.message-draft-shell .composer-options-section select { max-width: 100%; min-height: 44px; }

/* Task tabs share the concept stack layout while retaining task-specific controls. */
.task-tab-button {
background: #eef7ff;
border-color: #b8d7f0;
}

.task-tab-open {
color: inherit;
font: inherit;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
text-align: left;
}

.task-tab-content {
max-width: 1100px;
margin-inline: auto;
}

.task-tab-content .task-inspector-card {
margin-top: 12px;
}

.task-tab-button .close-tab {
background: transparent;
border: 0;
padding: 0;
}

.task-tab-button .close-tab:hover {
background: rgba(255, 0, 0, 0.1);
}
2 changes: 1 addition & 1 deletion tests/frontend/taskPanelCopyLink.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test.each(['popup', 'page'])('%s copies the exact task URL with feedback and no
const panel = require(base + 'components/taskPanel.js');
await panel.openTaskInPanel(taskId);
if (surface === 'page') {
document.querySelector('.task-open-in-tab-btn').click();
document.querySelector('.task-open-in-tasks-btn').click();
await panel.showGlobalTasks();
}
const root = document.querySelector(surface === 'popup' ? '#taskPanel' : '#globalTaskInspector');
Expand Down
2 changes: 1 addition & 1 deletion tests/frontend/taskPanelReference.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ test('Open in Tasks closes the popup and selects the exact task beyond the first
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();
document.querySelector('.task-open-in-tasks-btn').click();
expect(document.getElementById('taskPanel').classList.contains('hidden')).toBe(true);
expect(require(base + 'tabNavigation.js').activateTab).toHaveBeenCalledWith('globalTasksTab');
await panel.showGlobalTasks();
Expand Down
Loading
Loading