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 ? `` : ''}