diff --git a/src/server-dotnet/src/Dotbot.Server/wwwroot/css/dashboard.css b/src/server-dotnet/src/Dotbot.Server/wwwroot/css/dashboard.css
index 498e2a7c..503509b7 100644
--- a/src/server-dotnet/src/Dotbot.Server/wwwroot/css/dashboard.css
+++ b/src/server-dotnet/src/Dotbot.Server/wwwroot/css/dashboard.css
@@ -89,25 +89,22 @@ body {
margin-left: auto;
}
-/* === Tab Bar === */
+/* === View filter chips (#606 — sentence-case chips replace uppercase tabs,
+ * design spec #10; same switching JS, restyle only) === */
.tab-bar-container {
display: flex;
- gap: 0;
- padding: 0 20px;
- background: var(--bezel-dark);
- border-bottom: 1px solid var(--bezel-edge);
+ gap: 6px;
+ padding: 12px 20px 12px 0;
flex-shrink: 0;
}
.tab {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- padding: 10px 20px;
+ padding: 3px 12px;
background: transparent;
- border: none;
- border-bottom: 2px solid transparent;
+ border: 1px solid var(--bezel-edge);
+ border-radius: 3px;
color: var(--color-muted);
cursor: pointer;
transition: all 150ms ease;
@@ -118,8 +115,8 @@ body {
}
.tab.active {
color: var(--color-primary);
- border-bottom-color: var(--color-primary);
- text-shadow: 0 0 8px var(--primary-30);
+ border-color: var(--color-primary);
+ background: var(--primary-10);
}
/* === Main Content ===
diff --git a/src/shared/css/dotbot-shell.css b/src/shared/css/dotbot-shell.css
index 4d52399f..dd6a2d76 100644
--- a/src/shared/css/dotbot-shell.css
+++ b/src/shared/css/dotbot-shell.css
@@ -1,6 +1,6 @@
/* DOTBOT Shared Navigation Shell
* One shell, many rooms: the v4 navigation frame shared by Mothership and Outpost.
- * Spec: docs/DOTBOT-v4-DESIGN-IDEAS.md §7 — 44px top bar · 56px icon rail (pinnable
+ * Spec: docs/DOTBOT-v4-DESIGN-IDEAS.md #7 — 44px top bar · 56px icon rail (pinnable
* to 200px labelled mode) · 28px dispatch ticker · content capped at 1280px.
*
* Consumption order: dotbot-tokens.css → dotbot-crt.css → dotbot-shell.css
@@ -25,7 +25,7 @@
--shell-rail-w-pinned: 200px;
--shell-ticker-h: 28px;
--shell-content-max: 1280px;
- /* Scanline intensity: 6% on chrome, 0% on content (spec §11) */
+ /* Scanline intensity: 6% on chrome, 0% on content (spec #11) */
--shell-scanline-alpha: 0.06;
display: grid;
@@ -46,7 +46,7 @@
}
/* The shell owns its own chrome texture; suppress the global crt.css overlay
- * so content panels stay at 0% (spec §11). No-op until .shell markup exists. */
+ * so content panels stay at 0% (spec #11). No-op until .shell markup exists. */
body:has(.shell)::after {
content: none;
}
@@ -112,14 +112,19 @@ body:has(.shell)::after {
color: var(--color-primary);
}
-/* Search pill — the visible palette affordance. Behaviour lands with #553. */
+/* Search pill — the visible palette affordance (behaviour lands with #553).
+ * Centered mid-bar per the spec #7.3 sketch; clamp keeps it prominent on wide
+ * screens and lets it compress before identity values would truncate. */
.shell-search {
display: flex;
align-items: center;
gap: 8px;
+ flex: 0 1 clamp(180px, 24vw, 340px);
+ min-width: 140px;
margin-left: auto;
+ margin-right: auto;
padding: 5px 12px;
- min-width: 220px;
+ overflow: hidden;
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-muted);
diff --git a/src/shared/css/harness.html b/src/shared/css/harness.html
index 90288f78..2d63416e 100644
--- a/src/shared/css/harness.html
+++ b/src/shared/css/harness.html
@@ -147,7 +147,7 @@
SHELL HARNESS
+
diff --git a/src/ui/static/modules/config.js b/src/ui/static/modules/config.js
index a9063fd2..e14362f9 100644
--- a/src/ui/static/modules/config.js
+++ b/src/ui/static/modules/config.js
@@ -124,18 +124,6 @@ let currentTheme = null; // Current theme configuration
// Store discovered directories for use in relationship tree
let discoveredDirectories = [];
-// Pipeline column display limits (for infinite scroll)
-let pipelineDisplayLimits = {
- 'pipeline-todo': 10,
- 'pipeline-progress': 10,
- 'pipeline-done': 10
-};
-let pipelineTaskCounts = {
- 'pipeline-todo': 0,
- 'pipeline-progress': 0,
- 'pipeline-done': 0
-};
-
// Workflow viewer state
let currentWorkflowItem = { type: null, file: null };
diff --git a/src/ui/static/modules/decisions.js b/src/ui/static/modules/decisions.js
index 41203595..ce7441c1 100644
--- a/src/ui/static/modules/decisions.js
+++ b/src/ui/static/modules/decisions.js
@@ -8,6 +8,7 @@
let _decisions = [];
let _expandedDecisionId = null;
let _editingDecisionId = null; // null = create mode
+let _decisionFilter = 'all'; // status filter driven by the header chips (#606)
// ── Init ──────────────────────────────────────────────────────────────────────
@@ -15,9 +16,22 @@ async function initDecisions() {
_bindCreateButton();
_bindModal();
_bindListDelegation();
+ _bindFilterChips();
await _loadDecisions();
}
+function _bindFilterChips() {
+ const chips = document.getElementById('decision-filter-chips');
+ if (!chips) return;
+ chips.addEventListener('click', (e) => {
+ const chip = e.target.closest('.filter-chip');
+ if (!chip) return;
+ _decisionFilter = chip.dataset.decisionFilter || 'all';
+ chips.querySelectorAll('.filter-chip').forEach(c => c.classList.toggle('active', c === chip));
+ _renderList();
+ });
+}
+
function _bindListDelegation() {
const container = document.getElementById('decision-list');
if (!container) return;
@@ -72,11 +86,20 @@ function _renderList() {
return;
}
+ const visible = _decisionFilter === 'all'
+ ? _decisions
+ : _decisions.filter(d => (d.status || 'proposed') === _decisionFilter);
+
+ if (visible.length === 0) {
+ container.innerHTML = 'No decisions for this filter
';
+ return;
+ }
+
const statusOrder = ['proposed', 'accepted', 'deprecated', 'superseded'];
const statusLabels = { proposed: 'Proposed', accepted: 'Accepted', deprecated: 'Deprecated', superseded: 'Superseded' };
const groups = {};
- for (const dec of _decisions) {
+ for (const dec of visible) {
const s = dec.status || 'proposed';
if (!groups[s]) groups[s] = [];
groups[s].push(dec);
diff --git a/src/ui/static/modules/icons.js b/src/ui/static/modules/icons.js
index 8858c542..73b80219 100644
--- a/src/ui/static/modules/icons.js
+++ b/src/ui/static/modules/icons.js
@@ -56,12 +56,6 @@ function replaceIconPlaceholders() {
const stopBtn = document.querySelector('.ctrl-btn[data-action="stop"]');
if (stopBtn) stopBtn.innerHTML = getIcon('stop') + ' STOP';
- // Replace hamburger menu with menu icon
- const hamburger = document.getElementById('hamburger-menu');
- if (hamburger) {
- hamburger.innerHTML = getIcon('menu', 24);
- }
-
// Replace sidebar toggles with expand icons
document.querySelectorAll('.sidebar-toggle').forEach(toggle => {
toggle.innerHTML = getIcon('expandMore', 16);
diff --git a/src/ui/static/modules/layout.js b/src/ui/static/modules/layout.js
index 876763d8..6dbc14d6 100644
--- a/src/ui/static/modules/layout.js
+++ b/src/ui/static/modules/layout.js
@@ -1,17 +1,10 @@
/**
- * Layout persistence: draggable panel splitter + resizable pipeline columns.
- * Storage keys: dotbot:layout:sidebarWidth, dotbot:layout:columnWidths
+ * Shared localStorage helpers (dotbot:* keys).
+ * The splitter/column-resize machinery that used to live here died with the
+ * contextual sidebar and the pipeline kanban (#606); these helpers remain
+ * because other modules persist state through them (e.g. shell.js rail pin).
*/
-const SIDEBAR_MIN = 160;
-const SIDEBAR_MAX = 520;
-const SIDEBAR_DEFAULT = 280;
-const COLUMN_MIN = 120;
-const STORAGE_SIDEBAR = 'dotbot:layout:sidebarWidth';
-const STORAGE_COLUMNS = 'dotbot:layout:columnWidths';
-
-// ── Storage helpers ──────────────────────────────────────────────────────────
-
function layoutGet(key) {
try { return window.localStorage.getItem(key); } catch (e) { return null; }
}
@@ -23,174 +16,3 @@ function layoutSet(key, value) {
function layoutRemove(key) {
try { window.localStorage.removeItem(key); } catch (e) { /* ignore */ }
}
-
-// ── Sidebar width ────────────────────────────────────────────────────────────
-
-function applySidebarWidth(px) {
- document.documentElement.style.setProperty('--sidebar-width', `${px}px`);
-}
-
-function restoreSidebarWidth() {
- const stored = layoutGet(STORAGE_SIDEBAR);
- if (stored) {
- const px = parseInt(stored, 10);
- if (px >= SIDEBAR_MIN && px <= SIDEBAR_MAX) applySidebarWidth(px);
- }
-}
-
-function initPanelSplitter() {
- const splitter = document.getElementById('panel-splitter');
- const layout = document.getElementById('main-layout');
- if (!splitter || !layout) return;
-
- let dragging = false;
- let startX = 0;
- let startWidth = 0;
-
- splitter.addEventListener('mousedown', (e) => {
- dragging = true;
- startX = e.clientX;
- startWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width'), 10) || SIDEBAR_DEFAULT;
- splitter.classList.add('dragging');
- document.body.style.cursor = 'col-resize';
- document.body.style.userSelect = 'none';
- e.preventDefault();
- });
-
- document.addEventListener('mousemove', (e) => {
- if (!dragging) return;
- const delta = e.clientX - startX;
- const newWidth = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, startWidth + delta));
- applySidebarWidth(newWidth);
- });
-
- document.addEventListener('mouseup', () => {
- if (!dragging) return;
- dragging = false;
- splitter.classList.remove('dragging');
- document.body.style.cursor = '';
- document.body.style.userSelect = '';
- const current = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width'), 10);
- if (current !== SIDEBAR_DEFAULT) {
- layoutSet(STORAGE_SIDEBAR, current);
- } else {
- layoutRemove(STORAGE_SIDEBAR);
- }
- });
-
- splitter.addEventListener('dblclick', () => {
- applySidebarWidth(SIDEBAR_DEFAULT);
- layoutRemove(STORAGE_SIDEBAR);
- });
-}
-
-// ── Pipeline column resize ───────────────────────────────────────────────────
-
-function loadColumnWidths() {
- try {
- const raw = layoutGet(STORAGE_COLUMNS);
- return raw ? JSON.parse(raw) : {};
- } catch (e) { return {}; }
-}
-
-function saveColumnWidths(map) {
- try { layoutSet(STORAGE_COLUMNS, JSON.stringify(map)); } catch (e) { /* ignore */ }
-}
-
-function getColumnKey(col) {
- const label = col.querySelector('.column-label');
- return label ? label.textContent.trim().replace(/\s+/g, '_') : null;
-}
-
-function applyColumnWidth(col, px) {
- col.style.flex = 'none';
- col.style.width = `${px}px`;
-}
-
-function resetColumnWidth(col) {
- col.style.flex = '';
- col.style.width = '';
-}
-
-function initColumnResizeHandles() {
- const container = document.querySelector('.pipeline-container');
- if (!container) return;
-
- const columns = Array.from(container.querySelectorAll('.pipeline-column'));
- const widths = loadColumnWidths();
-
- columns.forEach((col) => {
- const key = getColumnKey(col);
- if (key && widths[key]) {
- applyColumnWidth(col, widths[key]);
- }
-
- const header = col.querySelector('.column-header');
- if (!header) return;
-
- const handle = document.createElement('div');
- handle.className = 'column-resize-handle';
- handle.title = 'Drag to resize · Double-click to reset';
- header.appendChild(handle);
-
- let dragging = false;
- let startX = 0;
- let startWidth = 0;
-
- handle.addEventListener('mousedown', (e) => {
- dragging = true;
- startX = e.clientX;
- startWidth = col.getBoundingClientRect().width;
- handle.classList.add('dragging');
- document.body.style.cursor = 'col-resize';
- document.body.style.userSelect = 'none';
- e.preventDefault();
- e.stopPropagation();
- });
-
- document.addEventListener('mousemove', (e) => {
- if (!dragging) return;
- const delta = e.clientX - startX;
- const newWidth = Math.max(COLUMN_MIN, startWidth + delta);
- applyColumnWidth(col, newWidth);
- });
-
- document.addEventListener('mouseup', () => {
- if (!dragging) return;
- dragging = false;
- handle.classList.remove('dragging');
- document.body.style.cursor = '';
- document.body.style.userSelect = '';
-
- if (key) {
- const map = loadColumnWidths();
- map[key] = Math.round(col.getBoundingClientRect().width);
- saveColumnWidths(map);
- }
- });
-
- handle.addEventListener('dblclick', (e) => {
- e.stopPropagation();
- resetColumnWidth(col);
- if (key) {
- const map = loadColumnWidths();
- delete map[key];
- if (Object.keys(map).length) {
- saveColumnWidths(map);
- } else {
- layoutRemove(STORAGE_COLUMNS);
- }
- }
- });
- });
-}
-
-// ── Init ─────────────────────────────────────────────────────────────────────
-
-// Restore sidebar width before first paint to avoid flash.
-restoreSidebarWidth();
-
-document.addEventListener('DOMContentLoaded', () => {
- initPanelSplitter();
- initColumnResizeHandles();
-});
diff --git a/src/ui/static/modules/sidebar.js b/src/ui/static/modules/sidebar.js
index ba57acf7..c99a1357 100644
--- a/src/ui/static/modules/sidebar.js
+++ b/src/ui/static/modules/sidebar.js
@@ -20,24 +20,7 @@ function initSidebarCollapse() {
});
}
-/**
- * Initialize collapse for dynamically created sections
- * @param {HTMLElement} container - Container element
- */
-function initSidebarCollapseForContainer(container) {
- container.querySelectorAll('.sidebar-header').forEach(header => {
- header.addEventListener('click', () => {
- const section = header.closest('.sidebar-section');
- const content = section.querySelector('.sidebar-content');
- const isCollapsed = section.classList.toggle('collapsed');
- content.style.display = isCollapsed ? 'none' : 'block';
- const toggle = header.querySelector('.sidebar-toggle');
- if (toggle) {
- toggle.innerHTML = getIcon(isCollapsed ? 'chevronRight' : 'expandMore', 16);
- }
- });
- });
-}
+
/**
* Initialize the sidebar with dynamic content
@@ -199,18 +182,4 @@ function renderFlatItems(container, items, type, shortType) {
});
}
-/**
- * Initialize sidebar item click handlers
- */
-function initSidebarItemClicks() {
- document.querySelectorAll('.sidebar-item').forEach(item => {
- item.addEventListener('click', (e) => {
- e.stopPropagation();
- const type = item.dataset.type;
- const file = item.dataset.file;
- if (type && file) {
- showWorkflowItem(type, file);
- }
- });
- });
-}
+
diff --git a/src/ui/static/modules/tabs.js b/src/ui/static/modules/tabs.js
index 560540a6..3c46176a 100644
--- a/src/ui/static/modules/tabs.js
+++ b/src/ui/static/modules/tabs.js
@@ -37,38 +37,24 @@ function switchToTab(targetId) {
// Keep the shell rail's active marker in sync (modules/shell.js)
syncRailActive(targetId);
- // Switch context panel in left sidebar
+ // Run per-tab side effects
switchContextPanel(targetId);
}
/**
- * Switch context panel based on current tab
+ * Per-tab side effects (the contextual sidebar this used to drive was
+ * removed in #606 — the name survives for its many call sites).
* @param {string} tabId - Tab ID
*/
function switchContextPanel(tabId) {
- // Hide all context panels
- document.querySelectorAll('.context-panel').forEach(panel => {
- panel.classList.add('hidden');
- });
-
- // Show the context panel matching the tab
- const targetPanel = document.querySelector(`.context-panel[data-context="${tabId}"]`);
- if (targetPanel) {
- targetPanel.classList.remove('hidden');
- }
-
- // Start/stop process polling based on tab
- if (tabId === 'processes') {
+ // Tasks surface hosts the process list — poll while it's visible
+ if (tabId === 'tasks') {
startProcessPolling();
+ if (lastState?.tasks) updateTasksSurface(lastState.tasks);
} else {
stopProcessPolling();
}
- // Update task summary when switching to pipeline tab
- if (tabId === 'pipeline' && lastState?.tasks) {
- updateTaskSummary(lastState.tasks);
- }
-
// Reload decisions when switching to decisions tab
if (tabId === 'decisions') {
reloadDecisions();
@@ -93,36 +79,6 @@ function switchContextPanel(tabId) {
}
}
-/**
- * Update task summary in pipeline context panel
- * @param {Object} tasks - Tasks object from state
- */
-function updateTaskSummary(tasks) {
- // Update count badges in context panel
- setElementText('context-todo-count', tasks.todo || 0);
- setElementText('context-analysing-count', tasks.analysing || 0);
- // "Needs Input" surfaces everything waiting on a person — input-waiting plus
- // review-waiting tasks — matching the unified pipeline column (#500).
- setElementText('context-needs-input-count', (tasks.needs_input || 0) + (tasks.needs_review || 0));
- setElementText('context-analysed-count', tasks.analysed || 0);
- setElementText('context-progress-count', tasks.in_progress || 0);
- setElementText('context-done-count', tasks.done || 0);
- setElementText('context-skipped-count', tasks.skipped || 0);
- setElementText('context-cancelled-count', tasks.cancelled || 0);
-
- // Update progress bar - include all statuses in total
- const total = (tasks.todo || 0) + (tasks.analysing || 0) + (tasks.needs_input || 0) +
- (tasks.needs_review || 0) + (tasks.analysed || 0) + (tasks.in_progress || 0) +
- (tasks.done || 0);
- const percent = total > 0 ? Math.round((tasks.done / total) * 100) : 0;
-
- const progressBar = document.getElementById('context-progress-bar');
- const progressLabel = document.getElementById('context-progress-label');
-
- if (progressBar) progressBar.style.width = `${percent}%`;
- if (progressLabel) progressLabel.textContent = `${percent}%`;
-}
-
/**
* Initialize logo click to return to overview
*/
@@ -136,22 +92,3 @@ function initLogoClick() {
}
}
-/**
- * Initialize hamburger menu for mobile
- */
-function initHamburgerMenu() {
- const hamburger = document.getElementById('hamburger-menu');
- const sidebar = document.querySelector('.sidebar-left');
- const overlay = document.getElementById('mobile-overlay');
-
- if (!hamburger || !sidebar || !overlay) return;
-
- const toggleMenu = () => {
- hamburger.classList.toggle('active');
- sidebar.classList.toggle('mobile-open');
- overlay.classList.toggle('active');
- };
-
- hamburger.addEventListener('click', toggleMenu);
- overlay.addEventListener('click', toggleMenu);
-}
diff --git a/src/ui/static/modules/tasks-surface.js b/src/ui/static/modules/tasks-surface.js
new file mode 100644
index 00000000..83fdfaf5
--- /dev/null
+++ b/src/ui/static/modules/tasks-surface.js
@@ -0,0 +1,189 @@
+/**
+ * DOTBOT Control Panel - Unified Tasks Surface (#606/#551)
+ * One task list with status filter chips + slim detail panel, replacing the
+ * pipeline kanban and the Processes tab. Task actions (ignore/edit/delete +
+ * history/restore) come from roadmap-task-actions.js; the deep-view modal
+ * stays in tasks.js behind the panel's "Full detail" link.
+ */
+
+let activeTaskFilter = 'all';
+let selectedTaskId = null;
+let tasksDisplayLimit = 100;
+
+const TASK_FILTER_LABELS = {
+ 'todo': 'Todo',
+ 'analysing': 'Analysing',
+ 'needs-input': 'Needs input',
+ 'needs-review': 'Needs review',
+ 'analysed': 'Analysed',
+ 'in-progress': 'In progress',
+ 'done': 'Done',
+ 'skipped': 'Skipped',
+};
+
+function initTasksSurface() {
+ const chips = document.getElementById('task-filter-chips');
+ chips?.addEventListener('click', (e) => {
+ const chip = e.target.closest('.filter-chip');
+ if (!chip) return;
+ activeTaskFilter = chip.dataset.taskFilter || 'all';
+ tasksDisplayLimit = 100;
+ chips.querySelectorAll('.filter-chip').forEach(c => c.classList.toggle('active', c === chip));
+ if (lastState?.tasks) updateTasksSurface(lastState.tasks);
+ });
+
+ // Row clicks open the detail panel. Guard order matters: action buttons
+ // must win — roadmap-task-actions listens at document level, which fires
+ // AFTER this container listener, so its stopPropagation can't protect us.
+ document.getElementById('tasks-list')?.addEventListener('click', (e) => {
+ if (e.target.closest('[data-task-action], .roadmap-task-action, .roadmap-header-action')) return;
+ const showMore = e.target.closest('.tasks-show-more');
+ if (showMore) {
+ tasksDisplayLimit += 100;
+ if (lastState?.tasks) updateTasksSurface(lastState.tasks);
+ return;
+ }
+ const row = e.target.closest('.task-list-item');
+ if (row && row.dataset.taskId) {
+ selectedTaskId = row.dataset.taskId;
+ renderTaskDetailPanel(findTaskById(selectedTaskId));
+ document.querySelectorAll('#tasks-list .task-list-item').forEach(r =>
+ r.classList.toggle('selected', r.dataset.taskId === selectedTaskId));
+ }
+ });
+
+ document.getElementById('task-detail-panel')?.addEventListener('click', (e) => {
+ const full = e.target.closest('.task-detail-full-link');
+ if (full && selectedTaskId) {
+ const task = findTaskById(selectedTaskId);
+ if (task) showTaskModal(task);
+ }
+ });
+}
+
+/**
+ * Map the active filter to state lists. Statuses mirror the old kanban
+ * columns; needs-input combines input- and review-waiting (#500).
+ */
+function collectFilteredTasks(tasks) {
+ const withStatus = (list, status) => (Array.isArray(list) ? list : []).map(t => ({ task: t, status }));
+ const buckets = {
+ 'in-progress': [
+ ...withStatus(tasks.in_progress_list, 'in-progress'),
+ ...(tasks.current && !(tasks.in_progress_list || []).some(t => t.id === tasks.current.id)
+ ? [{ task: tasks.current, status: 'in-progress' }] : []),
+ ],
+ 'needs-input': [
+ ...withStatus(tasks.needs_input_list, 'needs-input'),
+ ...withStatus(tasks.needs_review_list, 'needs-review'),
+ ],
+ 'analysing': withStatus(tasks.analysing_list, 'analysing'),
+ 'analysed': withStatus(tasks.analysed_list, 'analysed'),
+ 'todo': withStatus(tasks.upcoming, 'todo'),
+ 'done': withStatus(tasks.recent_completed, 'done'),
+ 'skipped': withStatus(tasks.skipped_list, 'skipped'),
+ };
+
+ let rows;
+ if (activeTaskFilter === 'all') {
+ rows = [
+ ...buckets['in-progress'], ...buckets['needs-input'], ...buckets['analysing'],
+ ...buckets['analysed'], ...buckets['todo'], ...buckets['done'],
+ ...buckets['skipped'],
+ ];
+ } else {
+ rows = buckets[activeTaskFilter] || [];
+ }
+
+ if (pipelineWorkflowFilter) {
+ rows = rows.filter(r => r.task.workflow === pipelineWorkflowFilter);
+ }
+ return rows;
+}
+
+/**
+ * Re-render the Tasks surface from state. Replaces updatePipelineView().
+ */
+function updateTasksSurface(tasks) {
+ if (!tasks) return;
+ if (typeof normalizeRoadmapTaskState === 'function') {
+ normalizeRoadmapTaskState({ tasks });
+ }
+ updatePipelineFilterOptions();
+
+ const container = document.getElementById('tasks-list');
+ if (!container) return;
+
+ const rows = collectFilteredTasks(tasks);
+ if (rows.length === 0) {
+ container.innerHTML = 'No tasks for this filter
';
+ } else {
+ const visible = rows.slice(0, tasksDisplayLimit);
+ const rowsHtml = visible.map(({ task, status }) => {
+ const ignoreState = task.ignore_state || {};
+ const dimmed = ignoreState.effective ? ' ignored' : '';
+ const selected = task.id === selectedTaskId ? ' selected' : '';
+ const actions = (status === 'todo' && typeof buildRoadmapTaskActionsMarkup === 'function')
+ ? buildRoadmapTaskActionsMarkup(task, 'todo') : '';
+ const meta = [task.category, task.workflow].filter(Boolean).map(escapeHtml).join(' · ');
+ return `
+
+ ${TASK_FILTER_LABELS[status] || status}
+ ${escapeHtml(task.name || task.id || 'Unknown')}
+ ${meta}
+ ${actions}
+
`;
+ }).join('');
+ const more = rows.length > tasksDisplayLimit
+ ? ``
+ : '';
+ container.innerHTML = rowsHtml + more;
+ }
+
+ updateTasksProgressBar(tasks);
+
+ // Keep the detail panel in sync with polled state
+ if (selectedTaskId) {
+ const task = findTaskById(selectedTaskId);
+ if (task) {
+ renderTaskDetailPanel(task);
+ } else {
+ selectedTaskId = null;
+ renderTaskDetailPanel(null);
+ }
+ }
+}
+
+function updateTasksProgressBar(tasks) {
+ const total = (tasks.todo || 0) + (tasks.analysing || 0) + (tasks.needs_input || 0) +
+ (tasks.needs_review || 0) + (tasks.analysed || 0) + (tasks.in_progress || 0) +
+ (tasks.done || 0);
+ const percent = total > 0 ? Math.round(((tasks.done || 0) / total) * 100) : 0;
+ const bar = document.getElementById('tasks-progress-bar');
+ if (bar) bar.style.width = `${percent}%`;
+}
+
+/**
+ * Slim detail panel: identity + requirements summary + actions.
+ * "Full detail" opens the existing 6-section modal (tasks.js).
+ */
+function renderTaskDetailPanel(task) {
+ const panel = document.getElementById('task-detail-panel');
+ if (!panel) return;
+ if (!task) {
+ panel.innerHTML = 'Select a task to see details
';
+ return;
+ }
+ const actions = (task.status === 'todo' && typeof buildRoadmapTaskActionsMarkup === 'function')
+ ? `${buildRoadmapTaskActionsMarkup(task, 'todo')}
` : '';
+ panel.innerHTML = `
+
+ ${actions}
+
+ ${buildOverviewSection(task)}
+ ${buildRequirementsSection(task)}
+
`;
+}
diff --git a/src/ui/static/modules/tasks.js b/src/ui/static/modules/tasks.js
index 83249bf6..291293b2 100644
--- a/src/ui/static/modules/tasks.js
+++ b/src/ui/static/modules/tasks.js
@@ -32,6 +32,12 @@ function initTaskClicks() {
return;
}
+ // Rows inside the Tasks surface open the detail panel (tasks-surface.js),
+ // not the modal — the modal is reachable via the panel's "Full detail" link.
+ if (e.target.closest('#tasks-list')) {
+ return;
+ }
+
const taskItem = e.target.closest('.task-list-item, .pipeline-task');
if (taskItem && taskItem.dataset.taskId) {
const task = findTaskById(taskItem.dataset.taskId);
diff --git a/src/ui/static/modules/ui-updates.js b/src/ui/static/modules/ui-updates.js
index f92ada87..6a96c9b3 100644
--- a/src/ui/static/modules/ui-updates.js
+++ b/src/ui/static/modules/ui-updates.js
@@ -19,7 +19,7 @@ function updateUI(state) {
updateCurrentTask(state.tasks.current);
updateUpcomingTasks(state.tasks.upcoming);
updateCompletedTasks(state.tasks.recent_completed, state.tasks.skipped_list);
- updatePipelineView(state.tasks);
+ updateTasksSurface(state.tasks);
updateControlSignalStatus(state.control);
updateControlButtonStates(state.session, state.control, state.loops);
@@ -28,11 +28,6 @@ function updateUI(state) {
updateSteeringPanel(state.instances);
}
- // Update task summary in pipeline context panel
- if (state.tasks) {
- updateTaskSummary(state.tasks);
- }
-
// Update per-workflow control LEDs/buttons
if (state.workflows && typeof updateWorkflowControlStates === 'function') {
updateWorkflowControlStates(state.workflows);
@@ -76,25 +71,17 @@ function updateTimestamp(instanceId) {
* @param {Object} tasks - Tasks object from state
*/
function updateTaskCounts(tasks) {
- // Overview stats
+ // Tasks-surface chip badges (#606)
setElementText('todo-count', tasks.todo);
setElementText('progress-count', tasks.in_progress);
setElementText('done-count', getCompletedTaskCount(tasks));
setElementText('analysing-count', tasks.analysing || 0);
- setElementText('needs-input-count', tasks.needs_input || 0);
+ // Needs input combines input- and review-waiting, matching the chip's list (#500)
+ setElementText('needs-input-count', (tasks.needs_input || 0) + (tasks.needs_review || 0));
setElementText('analysed-count', tasks.analysed || 0);
+ setElementText('skipped-count', tasks.skipped || 0);
+ setElementText('cancelled-count', tasks.cancelled || 0);
- // Pipeline counts - Unified pipeline
- setElementText('pipeline-todo-count', tasks.todo);
- setElementText('pipeline-working-count', (tasks.analysing || 0) + (tasks.in_progress || 0));
- setElementText('pipeline-needs-input-count', (tasks.needs_input || 0) + (tasks.needs_review || 0));
- setElementText('pipeline-done-count', getCompletedTaskCount(tasks));
- // Legacy pipeline counts (kept for backward compat)
- setElementText('pipeline-analysing-count', tasks.analysing || 0);
- setElementText('pipeline-analysed-count', tasks.analysed || 0);
- setElementText('pipeline-ready-count', tasks.analysed || 0);
- setElementText('pipeline-progress-count', tasks.in_progress);
-
// Update action widget
if (typeof updateActionWidget === 'function') {
updateActionWidget(tasks.action_required || 0, { fromPoll: true });
@@ -393,169 +380,6 @@ function updateCompletedTasks(tasks, skippedTasks) {
}).join('');
}
-/**
- * Update pipeline view
- * @param {Object} tasks - Tasks object from state
- */
-function updatePipelineView(tasks) {
- let upcoming = Array.isArray(tasks.upcoming) ? tasks.upcoming : [];
- let completed = Array.isArray(tasks.recent_completed) ? tasks.recent_completed : [];
- let analysing = Array.isArray(tasks.analysing_list) ? tasks.analysing_list : [];
- let needsInput = Array.isArray(tasks.needs_input_list) ? tasks.needs_input_list : [];
- let needsReview = Array.isArray(tasks.needs_review_list) ? tasks.needs_review_list : [];
- let analysed = Array.isArray(tasks.analysed_list) ? tasks.analysed_list : [];
- let inProgress = Array.isArray(tasks.in_progress_list)
- ? tasks.in_progress_list
- : (tasks.current ? [tasks.current] : []);
-
- // Apply workflow filter if set
- if (pipelineWorkflowFilter) {
- const wf = pipelineWorkflowFilter;
- upcoming = upcoming.filter(t => t.workflow === wf);
- completed = completed.filter(t => t.workflow === wf);
- analysing = analysing.filter(t => t.workflow === wf);
- needsInput = needsInput.filter(t => t.workflow === wf);
- needsReview = needsReview.filter(t => t.workflow === wf);
- analysed = analysed.filter(t => t.workflow === wf);
- inProgress = inProgress.filter(t => t.workflow === wf);
- }
-
- // Update filter dropdown options from state.workflows
- updatePipelineFilterOptions();
-
- // Unified pipeline columns
- updatePipelineColumn('pipeline-todo', upcoming, 'todo');
-
- // "Working" combines analysing + analysed + in-progress (all actively being processed)
- const working = [...analysing, ...analysed, ...inProgress];
- // Tag each task with its phase for sub-label display
- working.forEach(t => {
- if (analysing.includes(t)) t._phase = 'analysing';
- else if (analysed.includes(t)) t._phase = 'ready';
- else t._phase = 'executing';
- });
- updatePipelineColumn('pipeline-working', working, 'active');
-
- // "Needs Input" surfaces everything waiting on a person: tasks parked for
- // human input plus tasks parked for human review. Tag review tasks so the
- // card renders a distinct chip (#500). The Review Required panel is unchanged.
- needsReview.forEach(t => { t._waitKind = 'review'; });
- const waitingOnPerson = [...needsInput, ...needsReview];
- updatePipelineColumn('pipeline-needs-input', waitingOnPerson, 'needs-input');
- const skipped = Array.isArray(tasks.skipped_list) ? tasks.skipped_list : [];
- const doneAndSkipped = [...completed, ...skipped];
- updatePipelineColumn('pipeline-done', doneAndSkipped, 'done');
-
- // Legacy columns (for backward compat if old HTML is cached)
- updatePipelineColumn('pipeline-analysing', analysing, 'analysing');
- updatePipelineColumn('pipeline-analysed', analysed, 'analysed');
- updatePipelineColumn('pipeline-ready', analysed, 'ready');
- updatePipelineColumn('pipeline-progress', inProgress, 'active');
-}
-
-/**
- * Update a pipeline column
- * @param {string} containerId - Container element ID
- * @param {Array} tasks - Tasks to display
- * @param {string} type - Column type (todo, active, done)
- */
-function updatePipelineColumn(containerId, tasks, type) {
- const container = document.getElementById(containerId);
- if (!container) return;
-
- // Ensure tasks is an array
- const taskList = Array.isArray(tasks) ? tasks : [];
-
- // Track total task count for infinite scroll
- pipelineTaskCounts[containerId] = taskList.length;
-
- if (taskList.length === 0) {
- container.innerHTML = `No tasks
`;
- return;
- }
-
- // Get display limit for this column
- const limit = pipelineDisplayLimits[containerId] || 10;
- const visibleTasks = taskList.slice(0, limit);
-
- const taskMarkup = visibleTasks.map(task => {
- const priorityClass = task.priority == 1 ? 'priority-high' :
- task.priority == 2 ? 'priority-med' : '';
- const ignoreState = task.ignore_state || {};
- const roadmapClasses = [
- ignoreState.effective ? 'ignored' : '',
- ignoreState.manual ? 'manual-ignored' : '',
- ignoreState.auto ? 'blocked' : ''
- ].filter(Boolean).join(' ');
-
- // Format duration or completed date for done items
- let completedBadge = '';
- if (type === 'done' && task.status === 'skipped') {
- completedBadge = `skipped`;
- } else if (type === 'done' && task.completed_at) {
- const duration = formatTaskDuration(task) || formatCompactDate(task.completed_at);
- completedBadge = `${duration}`;
- }
-
- // Show phase sub-label for tasks in the "Working" column
- const phaseLabel = task._phase ? `${escapeHtml(task._phase)}` : '';
- // Distinguish review-waiting tasks from input-waiting tasks in the
- // shared "Needs Input" column (#500).
- const waitKindLabel = task._waitKind === 'review'
- ? `⚲ review`
- : '';
- const roadmapStateTags = typeof buildRoadmapTaskStatusTags === 'function'
- ? buildRoadmapTaskStatusTags(task, type)
- : '';
- const roadmapIgnoreHint = typeof buildRoadmapTaskIgnoreHint === 'function'
- ? buildRoadmapTaskIgnoreHint(task, type)
- : '';
- const roadmapActions = typeof buildRoadmapTaskActionsMarkup === 'function'
- ? buildRoadmapTaskActionsMarkup(task, type)
- : '';
-
- return `
-
-
${escapeHtml(task.id || '')}
-
${escapeHtml(task.name || task.id || 'Unknown')}
-
- ${task.category ? `${escapeHtml(task.category)}` : ''}
- ${task.workflow ? `${escapeHtml(task.workflow)}` : ''}
- ${task.type && task.type !== 'prompt' ? `${escapeHtml(task.type)}` : ''}
- ${phaseLabel}
- ${waitKindLabel}
- ${type === 'active' && !task._phase ? '↻ agent' : ''}
- ${roadmapStateTags}
-
- ${roadmapIgnoreHint}
- ${roadmapActions}
- ${completedBadge}
-
- `;
- }).join('');
-
- // Reveal-more affordance. The column caps rendering at `limit` and relies on
- // the scroll handler to load more, but a column that doesn't overflow never
- // fires a scroll event — leaving the remaining tasks hidden while the count
- // claims otherwise (#454). A visible button guarantees every task is reachable
- // regardless of whether the column scrolls.
- const remaining = taskList.length - visibleTasks.length;
- const loadMoreMarkup = remaining > 0
- ? ``
- : '';
-
- container.innerHTML = taskMarkup + loadMoreMarkup;
-
- if (remaining > 0) {
- const loadMoreBtn = container.querySelector('.pipeline-load-more');
- if (loadMoreBtn) {
- loadMoreBtn.addEventListener('click', () => {
- pipelineDisplayLimits[containerId] = (pipelineDisplayLimits[containerId] || 10) + remaining;
- if (lastState?.tasks) updatePipelineView(lastState.tasks);
- });
- }
- }
-}
/**
* Update pipeline filter dropdown options from latest state
*/
@@ -572,38 +396,6 @@ function updatePipelineFilterOptions() {
workflows.map(name => ``).join('');
}
-function initPipelineInfiniteScroll() {
- const columnIds = [
- 'pipeline-todo', 'pipeline-working', 'pipeline-needs-input', 'pipeline-done',
- // Legacy columns (backward compat)
- 'pipeline-analysing', 'pipeline-analysed', 'pipeline-ready', 'pipeline-progress'
- ];
-
- columnIds.forEach(containerId => {
- const container = document.getElementById(containerId);
- if (!container) return;
-
- container.addEventListener('scroll', () => {
- // Check if scrolled near bottom (within 50px)
- const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
- if (scrollBottom < 50) {
- const currentLimit = pipelineDisplayLimits[containerId] || 10;
- const totalTasks = pipelineTaskCounts[containerId] || 0;
-
- // Load more if there are more tasks available
- if (currentLimit < totalTasks) {
- pipelineDisplayLimits[containerId] = currentLimit + 5;
-
- // Re-render with updated limit
- if (lastState?.tasks) {
- updatePipelineView(lastState.tasks);
- }
- }
- }
- });
- });
-}
-
/**
* Update control signal status display
* @param {Object} control - Control object from state
@@ -766,7 +558,9 @@ async function initProjectName() {
const workflowName = info.workflow || null;
currentWorkflowName = workflowName;
updateWorkflowBadge(workflowName);
- updateWorkflowPills(info.installed_workflows || []);
+ // Topbar pills were dropped in #606 (spec #7.3 topbar has no pills);
+ // the installed list still feeds the workflow-launch CTA.
+ installedWorkflows = info.installed_workflows || [];
updateFrameworkBanner(info.framework || null);
} else {
projectName = 'autonomous';
@@ -857,29 +651,6 @@ function updateFrameworkBanner(framework) {
banner.title = tooltipLines.join('\n');
}
-/**
- * Update workflow pills in header from installed_workflows
- * @param {Array} workflows - Array of workflow name strings
- */
-function updateWorkflowPills(workflows) {
- const container = document.getElementById('workflow-pills');
- if (!container) return;
- installedWorkflows = workflows || [];
- if (workflows && workflows.length > 0) {
- // Strip the registry prefix (e.g. "iwg:iwg-bs-scoring" -> "iwg-bs-scoring")
- // so we can de-duplicate against the active workflow badge
- const activeBase = currentWorkflowName
- ? currentWorkflowName.replace(/^[^:]+:/, '')
- : null;
- const filtered = workflows.filter(name => name !== currentWorkflowName && name !== activeBase);
- container.innerHTML = filtered.map(name =>
- `${escapeHtml(name)}`
- ).join('');
- } else {
- container.innerHTML = '';
- }
-}
-
/**
* Update footer mission text
*/
diff --git a/tests/e2e/specs/lists.spec.ts b/tests/e2e/specs/lists.spec.ts
index 276f87b8..b597a38b 100644
--- a/tests/e2e/specs/lists.spec.ts
+++ b/tests/e2e/specs/lists.spec.ts
@@ -35,13 +35,13 @@ test.describe("Task list rendering (Roadmap tab)", () => {
timeout: 10_000,
});
- await page.locator('.shell-rail-item[data-tab="pipeline"]').click();
- await expect(page.locator("#tab-pipeline")).toHaveClass(/active/);
+ await page.locator('.shell-rail-item[data-tab="tasks"]').click();
+ await expect(page.locator("#tab-tasks")).toHaveClass(/active/);
- const rows = page.locator("#upcoming-tasks .task-list-item");
+ const rows = page.locator("#tasks-list .task-list-item");
await expect(rows).toHaveCount(3, { timeout: 10_000 });
- const names = page.locator("#upcoming-tasks .task-list-item-name");
+ const names = page.locator("#tasks-list .task-list-item-name");
await expect(names).toContainText([
"list-spec-alpha",
"list-spec-bravo",
@@ -54,10 +54,8 @@ test.describe("Task list rendering (Roadmap tab)", () => {
await expect(page.locator("#todo-count")).toHaveText("0", {
timeout: 10_000,
});
- await page.locator('.shell-rail-item[data-tab="pipeline"]').click();
- await expect(page.locator("#upcoming-tasks .task-list-item")).toHaveCount(
- 0,
- );
+ await page.locator('.shell-rail-item[data-tab="tasks"]').click();
+ await expect(page.locator("#tasks-list .task-list-item")).toHaveCount(0);
});
});
@@ -89,8 +87,8 @@ test.describe("Process list rendering (Processes tab)", () => {
// Cannot use overview-active as a guard because index.html hardcodes
// `class="tab active"` on the overview button.
await expect(page.locator("body")).toHaveAttribute("data-app-ready", "1");
- await page.locator('.shell-rail-item[data-tab="processes"]').click();
- await expect(page.locator("#tab-processes")).toHaveClass(/active/);
+ await page.locator('.shell-rail-item[data-tab="tasks"]').click();
+ await expect(page.locator("#tab-tasks")).toHaveClass(/active/);
const row = page.locator(
`#process-list .process-row[data-process-id="${proc.id}"]`,
@@ -102,8 +100,8 @@ test.describe("Process list rendering (Processes tab)", () => {
test("renders empty state when no process JSONs exist", async ({ page }) => {
await page.goto("/");
await expect(page.locator("body")).toHaveAttribute("data-app-ready", "1");
- await page.locator('.shell-rail-item[data-tab="processes"]').click();
- await expect(page.locator("#tab-processes")).toHaveClass(/active/);
+ await page.locator('.shell-rail-item[data-tab="tasks"]').click();
+ await expect(page.locator("#tab-tasks")).toHaveClass(/active/);
await expect(page.locator("#process-list .empty-state")).toBeVisible({
timeout: 10_000,
diff --git a/tests/e2e/specs/polling.spec.ts b/tests/e2e/specs/polling.spec.ts
index 779b9637..fb7e4b63 100644
--- a/tests/e2e/specs/polling.spec.ts
+++ b/tests/e2e/specs/polling.spec.ts
@@ -36,7 +36,6 @@ test.describe("State polling reflects backend state in the DOM", () => {
await expect(page.locator("#todo-count")).toHaveText("1", {
timeout: 10_000,
});
- await expect(page.locator("#pipeline-todo-count")).toHaveText("1");
});
test("moving a task from todo to in-progress shifts the counts", async ({
diff --git a/tests/e2e/specs/tabs.spec.ts b/tests/e2e/specs/tabs.spec.ts
index 76756555..737f5316 100644
--- a/tests/e2e/specs/tabs.spec.ts
+++ b/tests/e2e/specs/tabs.spec.ts
@@ -2,9 +2,8 @@ import { test, expect, Page } from "@playwright/test";
const TABS = [
"overview",
+ "tasks",
"product",
- "pipeline",
- "processes",
"decisions",
"workflow",
"settings",
diff --git a/tests/e2e/specs/tasks.spec.ts b/tests/e2e/specs/tasks.spec.ts
new file mode 100644
index 00000000..e1b23ee3
--- /dev/null
+++ b/tests/e2e/specs/tasks.spec.ts
@@ -0,0 +1,94 @@
+import { test, expect } from "@playwright/test";
+import { seedTask, removeTask, type SeededTask } from "../helpers/fixture";
+
+// Unified Tasks surface (#606): filter chips + task list + detail panel.
+test.describe("Tasks surface", () => {
+ const seeded: SeededTask[] = [];
+
+ test.afterEach(async () => {
+ while (seeded.length > 0) {
+ const t = seeded.pop()!;
+ try {
+ removeTask(t);
+ } catch {}
+ }
+ });
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/");
+ await expect(page.locator("body")).toHaveAttribute("data-app-ready", "1", {
+ timeout: 15_000,
+ });
+ });
+
+ test("chip filtering changes the visible row set", async ({ page }) => {
+ seeded.push(seedTask("todo", { name: "tasks-spec-todo" }));
+
+ await expect(page.locator("#todo-count")).toHaveText("1", {
+ timeout: 10_000,
+ });
+
+ await page.locator('.shell-rail-item[data-tab="tasks"]').click();
+ await expect(page.locator("#tab-tasks")).toHaveClass(/active/);
+
+ // All filter shows the row
+ await page.locator('.filter-chip[data-task-filter="all"]').click();
+ await expect(page.locator("#tasks-list .task-list-item")).toHaveCount(1);
+
+ // Done filter hides it
+ await page.locator('.filter-chip[data-task-filter="done"]').click();
+ await expect(
+ page.locator('.filter-chip[data-task-filter="done"]'),
+ ).toHaveClass(/active/);
+ await expect(page.locator("#tasks-list .task-list-item")).toHaveCount(0);
+
+ // Todo filter shows it again
+ await page.locator('.filter-chip[data-task-filter="todo"]').click();
+ await expect(page.locator("#tasks-list .task-list-item")).toHaveCount(1);
+ });
+
+ test("row click opens the detail panel; Full detail opens the modal", async ({
+ page,
+ }) => {
+ seeded.push(seedTask("todo", { name: "tasks-spec-panel" }));
+
+ await expect(page.locator("#todo-count")).toHaveText("1", {
+ timeout: 10_000,
+ });
+ await page.locator('.shell-rail-item[data-tab="tasks"]').click();
+
+ const row = page.locator("#tasks-list .task-list-item").first();
+ await expect(row).toBeVisible({ timeout: 10_000 });
+ // Click the name, not the action buttons
+ await row.locator(".task-list-item-name").click();
+
+ const panel = page.locator("#task-detail-panel");
+ await expect(panel).toContainText("tasks-spec-panel");
+ await expect(panel.locator(".task-detail-full-link")).toBeVisible();
+
+ // Full detail opens the deep-view modal
+ await panel.locator(".task-detail-full-link").click();
+ await expect(page.locator("#task-modal")).toHaveClass(/visible/);
+ });
+
+ test("clicking a task action button does not open the detail panel", async ({
+ page,
+ }) => {
+ seeded.push(seedTask("todo", { name: "tasks-spec-guard" }));
+
+ await expect(page.locator("#todo-count")).toHaveText("1", {
+ timeout: 10_000,
+ });
+ await page.locator('.shell-rail-item[data-tab="tasks"]').click();
+
+ const row = page.locator("#tasks-list .task-list-item").first();
+ await expect(row).toBeVisible({ timeout: 10_000 });
+
+ // The edit action opens the edit modal — the panel must stay empty
+ await row.locator('[data-task-action="edit-task"]').click();
+ await expect(page.locator("#task-edit-modal")).toHaveClass(/visible/, {
+ timeout: 10_000,
+ });
+ await expect(page.locator("#task-detail-panel .empty-state")).toBeVisible();
+ });
+});