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 cd5e7936..e6ef7f27 100644 --- a/src/frontend/web/von_interface/static/js/components/taskPanel.js +++ b/src/frontend/web/von_interface/static/js/components/taskPanel.js @@ -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 @@ -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; @@ -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, @@ -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; @@ -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); + } } } @@ -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); @@ -3187,6 +3200,7 @@ function renderTaskInspector(task, detailState) { ${escapeHtml(getPriorityInfo(detailTask.priority).label)} ${renderTaskExecuteWithVonButton(detailTask, 'task-inspector-execute-btn')} ${renderTaskDiscussButton(detailTask, 'task-inspector-discuss-btn')} + ${!inTaskTab ? renderOpenTaskTabButton(taskId) : ''} @@ -3368,6 +3382,143 @@ function renderTaskInspector(task, detailState) { `; } +function renderOpenTaskTabButton(taskId) { + return ``; +} + +// 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 = `${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; @@ -3574,7 +3725,8 @@ function renderTaskItem(task) { - ${!_isGlobalTabMode ? `` : ''} + ${renderOpenTaskTabButton(taskId)} + ${!_isGlobalTabMode ? `` : ''}