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 = ` +
+ ${escapeHtml(task.name || task.id || 'Task')} + Full detail → +
+ ${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(); + }); +});