From 1bd474f1caf2bdf578e02bfa72d5565d38fc78df Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Fri, 10 Jul 2026 18:26:33 +0300 Subject: [PATCH 01/16] feat(outpost): unified Tasks surface, rail 7 to 6 --- src/ui/static/app.js | 6 +- src/ui/static/css/views.css | 388 +++++++++++-------------- src/ui/static/index.html | 246 +++------------- src/ui/static/modules/config.js | 12 - src/ui/static/modules/tabs.js | 40 +-- src/ui/static/modules/tasks-surface.js | 188 ++++++++++++ src/ui/static/modules/tasks.js | 6 + src/ui/static/modules/ui-updates.js | 220 +------------- tests/e2e/specs/lists.spec.ts | 22 +- tests/e2e/specs/polling.spec.ts | 1 - tests/e2e/specs/tabs.spec.ts | 3 +- 11 files changed, 428 insertions(+), 704 deletions(-) create mode 100644 src/ui/static/modules/tasks-surface.js diff --git a/src/ui/static/app.js b/src/ui/static/app.js index 7c5c94d6..b0fdce49 100644 --- a/src/ui/static/app.js +++ b/src/ui/static/app.js @@ -43,12 +43,12 @@ document.addEventListener('DOMContentLoaded', async () => { initSidebarItemClicks(); await initProductNav(); initModalClose(); - initPipelineInfiniteScroll(); + initTasksSurface(); - // Pipeline workflow filter + // Tasks workflow filter document.getElementById('pipeline-workflow-filter')?.addEventListener('change', (e) => { pipelineWorkflowFilter = e.target.value || null; - if (lastState?.tasks) updatePipelineView(lastState.tasks); + if (lastState?.tasks) updateTasksSurface(lastState.tasks); }); initActions(); initNotifications(); diff --git a/src/ui/static/css/views.css b/src/ui/static/css/views.css index 32b65b06..d3e868c0 100644 --- a/src/ui/static/css/views.css +++ b/src/ui/static/css/views.css @@ -2,289 +2,256 @@ * Tab content views: pipeline, workflow, product, settings */ -/* ========== PIPELINE VIEW ========== */ -.pipeline-wrapper { +/* ========== TASKS SURFACE (#606) ========== */ +.tasks-surface { display: flex; flex-direction: column; - gap: 16px; + gap: 12px; height: 100%; } -.pipeline-stage { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} - -.pipeline-stage-header { +.tasks-toolbar { display: flex; align-items: center; + justify-content: space-between; gap: 12px; - padding: 8px 12px; - margin-bottom: 8px; + flex-wrap: wrap; } -.pipeline-stage-title { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--color-primary-dim); +.filter-chips { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; } -.pipeline-workflow-filter { - padding: 4px 8px; - background: var(--bg-screen); - border: 1px solid var(--primary-15); +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 12px; + background: transparent; + border: 1px solid var(--bezel-edge); border-radius: 3px; - color: var(--color-primary-dim); - font-family: var(--font-ui); - font-size: 9px; - text-transform: uppercase; - letter-spacing: 0.05em; + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-muted); cursor: pointer; transition: all 0.15s ease; } -.pipeline-workflow-filter:hover { - border-color: var(--primary-30); +.filter-chip:hover { + color: var(--color-primary); + background: var(--primary-05); } -.pipeline-workflow-filter:focus { - outline: none; - border-color: var(--color-secondary); +.filter-chip.active { + color: var(--color-primary); + border-color: var(--color-primary); + background: var(--primary-10); } -.pipeline-stage-title.analysis { - color: var(--color-secondary); +.chip-count { + font-size: 10px; + color: var(--color-primary-dim); } -.pipeline-stage-title.execution { - color: var(--color-secondary); +.chips-extra { + margin-left: 8px; + font-size: 10px; + color: var(--color-muted); } -.pipeline-container { +.tasks-toolbar-actions { display: flex; + align-items: center; gap: 8px; - flex: 1; - min-height: 0; - align-items: stretch; } -.pipeline-column { - flex: 1; - background: var(--bg-screen); - border: 1px solid var(--primary-12); - border-radius: 4px; +.tasks-progress-strip { display: flex; - flex-direction: column; - overflow: hidden; - background-image: - linear-gradient(var(--grid-color) 1px, transparent 1px), - linear-gradient(90deg, var(--grid-color) 1px, transparent 1px), - linear-gradient(var(--grid-color-strong) 1px, transparent 1px), - linear-gradient(90deg, var(--grid-color-strong) 1px, transparent 1px); - background-size: 10px 10px, 10px 10px, 50px 50px, 50px 50px; + align-items: center; + gap: 12px; } -.pipeline-column.highlight { - border-color: var(--secondary-30); +.tasks-progress-track { + flex: 1; + height: 6px; + background: var(--bg-screen); + border: 1px solid var(--bezel-edge); + border-radius: 3px; + overflow: hidden; } -.column-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 14px; - background: var(--primary-05); - border-bottom: 1px solid var(--primary-10); - position: relative; +.tasks-progress-bar { + height: 100%; + width: 0; + background: var(--color-primary); + box-shadow: 0 0 6px var(--primary-glow); + transition: width 0.3s ease; } -.column-resize-handle { - position: absolute; - right: -4px; - top: 0; - bottom: 0; - width: 8px; - cursor: col-resize; - z-index: 5; +.tasks-progress-label { + font-size: 10px; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; } -.column-resize-handle::after { - content: ''; - position: absolute; - left: 3px; - top: 20%; - bottom: 20%; - width: 2px; - background: transparent; - transition: background 0.15s ease; - border-radius: 1px; +.tasks-main { + display: grid; + grid-template-columns: 1fr minmax(280px, 340px); + gap: 12px; + min-height: 200px; } -.column-resize-handle:hover::after, -.column-resize-handle.dragging::after { - background: var(--color-primary-dim); +.tasks-list .task-list-item.tasks-row { + display: flex; + align-items: center; + gap: 10px; } -.column-label { - font-size: 10px; +.task-status-word { + flex: 0 0 auto; + min-width: 88px; + font-size: 9px; font-weight: 600; - letter-spacing: 0.08em; text-transform: uppercase; - color: var(--color-primary-dim); + letter-spacing: 0.05em; + color: var(--type-color, var(--color-muted)); } -.column-count { - font-size: 18px; - font-weight: 700; - color: var(--color-primary); - text-shadow: 0 0 10px var(--primary-glow); -} +.task-status-word.status-todo { color: var(--color-primary); } +.task-status-word.status-analysing, +.task-status-word.status-in-progress { color: var(--color-secondary); } +.task-status-word.status-needs-input, +.task-status-word.status-needs-review { color: var(--color-warning); } +.task-status-word.status-analysed { color: var(--color-info); } +.task-status-word.status-done { color: var(--color-success); } +.task-status-word.status-skipped { color: var(--color-muted); } -.column-items { +.tasks-row .task-list-item-name { flex: 1; - padding: 8px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 6px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -/* Animated flow arrows */ -.flow-arrow { - display: flex; - align-items: center; - justify-content: center; - color: var(--color-primary); - font-size: 24px; - text-shadow: 0 0 15px var(--primary-glow); - animation: flowPulse 1.5s ease-in-out infinite; +.tasks-row .task-row-actions { + flex: 0 0 auto; } -@keyframes flowPulse { - 0%, 100% { opacity: 0.3; transform: translateX(0); } - 50% { opacity: 1; transform: translateX(3px); } +.tasks-row.ignored { + opacity: 0.45; } -/* Pipeline task cards (full detail) */ -.pipeline-task { - padding: 10px 12px; - background: var(--bg-black-overlay-heavy); - border: 1px solid var(--primary-15); - border-radius: 3px; - cursor: pointer; - transition: all 0.15s ease; - position: relative; - padding-left: 14px; +.tasks-row.selected { + background: var(--primary-08); + border-color: var(--primary-30); } -.pipeline-task::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - background: var(--color-primary); - border-radius: 3px 0 0 3px; +.tasks-show-more { + display: block; + width: 100%; + margin-top: 8px; + text-align: center; } -.pipeline-task.priority-high::before { background: var(--color-error); } -.pipeline-task.priority-med::before { background: var(--color-primary); } -.pipeline-task.priority-low::before { background: var(--color-success); } - -.pipeline-task:hover { - background: var(--primary-08); - border-color: var(--primary-30); +.task-detail-panel { + background: var(--bg-module); + border: 1px solid var(--bezel-edge); + border-radius: 4px; + padding: 14px; + overflow-y: auto; + max-height: 60vh; } -.pipeline-task.active { - border-color: var(--color-secondary); - background: rgba(95, 179, 179, 0.1); +.task-detail-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; } -.pipeline-task.active::before { - background: var(--color-secondary); +.task-detail-title { + font-size: 13px; + font-weight: 600; + color: var(--color-primary); } -.pipeline-load-more { - display: block; - width: 100%; - margin-top: 4px; - padding: 6px 12px; - background: var(--primary-05); - border: 1px dashed var(--primary-30); - border-radius: 3px; - color: var(--label-color); - font-family: inherit; - font-size: 11px; - letter-spacing: 0.04em; +.task-detail-full-link { + font-size: 10px; + color: var(--color-secondary); text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; cursor: pointer; - transition: all 0.15s ease; } -.pipeline-load-more:hover { - background: var(--primary-08); - border-color: var(--primary-30); - color: var(--color-primary); +.task-detail-actions { + margin-bottom: 10px; } -.pipeline-task .task-id { - font-size: 8px; - color: var(--label-color); - margin-bottom: 4px; - font-family: var(--font-ui); - word-break: break-all; +.task-detail-body { + font-size: 12px; } -.pipeline-task .task-title { - font-size: 11px; - color: var(--color-primary); - line-height: 1.4; - margin-bottom: 6px; +.tasks-processes-section { + border-top: 1px solid var(--bezel-edge); + padding-top: 12px; } -.pipeline-task .task-tags { +.tasks-processes-header { display: flex; - gap: 4px; - flex-wrap: wrap; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; } -.pipeline-task .task-tag { - padding: 2px 6px; - background: var(--primary-10); - border-radius: 2px; - font-size: 8px; +.tasks-processes-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; color: var(--color-primary-dim); } -/* Review-waiting tasks share the "Needs Input" column but are flagged with a - warning-toned chip to distinguish them from input-waiting tasks (#500). */ -.pipeline-task .task-tag.tag-review { - color: var(--color-warning); - background: rgb(var(--color-warning-rgb) / 0.12); +.tasks-processes-counts { + font-size: 10px; + color: var(--color-muted); } -.pipeline-task:has(.completed-date) { - padding-bottom: 24px; +@media (max-width: 900px) { + .tasks-main { + grid-template-columns: 1fr; + } } -.pipeline-task > .completed-date { - position: absolute; - bottom: 6px; - right: 8px; - padding: 2px 8px; - background: var(--surface-tertiary); - color: var(--color-success); +.pipeline-workflow-filter { + padding: 4px 8px; + background: var(--bg-screen); + border: 1px solid var(--primary-15); + border-radius: 3px; + color: var(--color-primary-dim); font-family: var(--font-ui); - font-size: 8px; - border-radius: 10px; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + transition: all 0.15s ease; +} + +.pipeline-workflow-filter:hover { + border-color: var(--primary-30); } +.pipeline-workflow-filter:focus { + outline: none; + border-color: var(--color-secondary); +} /* ========== PRODUCT TAB ========== */ .product-viewer { display: flex; @@ -2756,35 +2723,6 @@ } /* ========== ROADMAP TASK ACTIONS ========== */ -.roadmap-column-header { - gap: 10px; -} - -.roadmap-column-actions { - display: flex; - align-items: center; - gap: 8px; - margin-left: auto; -} - -.pipeline-task.ignored { - background: rgba(222, 145, 74, 0.08); - border-color: rgba(222, 145, 74, 0.28); -} - -.pipeline-task.blocked { - border-style: dashed; - background: rgba(94, 120, 145, 0.08); -} - -.pipeline-task.manual-ignored::before { - background: var(--color-warning); -} - -.pipeline-task.blocked::before { - background: var(--color-secondary); -} - .roadmap-task-note { margin-top: 8px; font-size: 9px; diff --git a/src/ui/static/index.html b/src/ui/static/index.html index b624a2fd..cd4db6e7 100644 --- a/src/ui/static/index.html +++ b/src/ui/static/index.html @@ -75,9 +75,8 @@ - +
-
- - - - -
-
@@ -1086,8 +1071,6 @@
- - 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 3cd9810b..3c46176a 100644 --- a/src/ui/static/modules/tabs.js +++ b/src/ui/static/modules/tabs.js @@ -37,26 +37,16 @@ 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'); - } - // Tasks surface hosts the process list — poll while it's visible if (tabId === 'tasks') { startProcessPolling(); @@ -102,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); -} From 6fe2c17b0d38a96a0dc2995f06e96d74cea91402 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Mon, 13 Jul 2026 11:03:58 +0300 Subject: [PATCH 05/16] feat(outpost): drop installed-workflow pills from topbar --- src/shared/css/dotbot-shell.css | 6 ++--- src/shared/css/harness.html | 2 +- src/ui/static/css/layout.css | 38 +++-------------------------- src/ui/static/index.html | 1 - src/ui/static/modules/ui-updates.js | 27 +++----------------- 5 files changed, 10 insertions(+), 64 deletions(-) diff --git a/src/shared/css/dotbot-shell.css b/src/shared/css/dotbot-shell.css index 4d52399f..d7e498c6 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; } 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/polling.js b/src/ui/static/modules/polling.js index 9ebe7df6..09f2255c 100644 --- a/src/ui/static/modules/polling.js +++ b/src/ui/static/modules/polling.js @@ -172,6 +172,11 @@ async function pollActivity() { if (typeof Aether !== 'undefined') { Aether.processActivity(event); } + + // Send to the dispatch ticker (#607) + if (typeof Ticker !== 'undefined') { + Ticker.ingest(event); + } } // Update displays with latest of each type diff --git a/src/ui/static/modules/ticker.js b/src/ui/static/modules/ticker.js new file mode 100644 index 00000000..fd7bbd18 --- /dev/null +++ b/src/ui/static/modules/ticker.js @@ -0,0 +1,175 @@ +/** + * DOTBOT Control Panel - Dispatch Ticker (#607/#551) + * Two-mode shell footer: static (instance/last-update summary — the default) + * and feed (scrolling dispatch log fed from /api/activity/tail via + * pollActivity). Click toggles; choice persists per dotbot:shell:tickerMode. + * Dispatch voice per spec #4.2 — terse wire-report lines ending "— M" or + * "· stop"; events carry ids only, never task titles. + */ + +const Ticker = (function() { + const MODE_KEY = 'dotbot:shell:tickerMode'; // 'feed' | 'static' + const MAX_ITEMS = 8; + const MIN_CYCLE_S = 20; // spec #11: >=20s full cycle + const PX_PER_S = 60; + + const buffer = []; // ring buffer of dispatch line strings + let el = null; + let track = null; + let lastRendered = ''; // skip identical rebuilds (no phase reset) + let rebuildQueued = false; + let immediateQueued = false; + let showingPlaceholder = false; + + function init() { + el = document.getElementById('shell-ticker'); + track = document.getElementById('ticker-feed-track'); + if (!el || !track) return; + + if (layoutGet(MODE_KEY) === 'feed') { + el.dataset.mode = 'feed'; + } + + el.addEventListener('click', () => { + const feed = el.dataset.mode === 'feed'; + if (feed) { + delete el.dataset.mode; + } else { + el.dataset.mode = 'feed'; + } + layoutSet(MODE_KEY, feed ? 'static' : 'feed'); + lastRendered = ''; + render(); + el.blur(); // keep :focus-within from pausing right after a mouse toggle + }); + el.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + el.click(); + } + }); + + // OS flag changes must re-render: a CSS-frozen duplicated track is not + // the spec's "latest item shown static". + window.matchMedia('(prefers-reduced-motion: reduce)') + .addEventListener('change', () => { lastRendered = ''; render(); }); + } + + /** Called from pollActivity for every activity event. */ + function ingest(event) { + const line = formatDispatchLine(event); + if (!line) return; + buffer.push(line); + if (buffer.length > MAX_ITEMS) buffer.shift(); + queueRender(); + } + + function reducedMotion() { + return document.body.dataset.motion === 'reduced' || + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } + + /** Defer rebuilds to the loop boundary so the scroll never snaps back + * mid-cycle when events stream in every poll tick. */ + function queueRender() { + if (!el || el.dataset.mode !== 'feed') return; + // Placeholder has no scroll phase worth preserving — first real + // event replaces "no transmissions" immediately. Coalesce within a + // tick so a polled burst renders once with the whole batch, not the + // first event now and the rest a full animation cycle later. + if (reducedMotion() || !track.childElementCount || showingPlaceholder) { + if (immediateQueued) return; + immediateQueued = true; + setTimeout(() => { + immediateQueued = false; + render(); + }, 0); + return; + } + if (rebuildQueued) return; + rebuildQueued = true; + track.addEventListener('animationiteration', () => { + rebuildQueued = false; + render(); + }, { once: true }); + } + + function render() { + if (!el || !track || el.dataset.mode !== 'feed') return; + + const content = buffer.length + ? buffer.join(' · ') + : 'no transmissions. standing by.'; + const signature = `${reducedMotion() ? 'R' : 'S'}|${content}`; + if (signature === lastRendered) return; + lastRendered = signature; + showingPlaceholder = buffer.length === 0; + + track.replaceChildren(); + track.style.removeProperty('--ticker-duration'); + + if (reducedMotion()) { + // Latest item shown static (spec #11) + appendEntry(buffer.length ? buffer[buffer.length - 1] : content); + return; + } + + // Fill one block to at least the container width, then duplicate the + // whole block exactly once — identical halves make the -50% keyframe + // loop seamless (odd repeat counts put the midpoint mid-sequence). + const containerWidth = el.querySelector('.shell-ticker-feed').clientWidth || 800; + appendEntry(content); + while (track.scrollWidth < containerWidth) { + appendEntry(content); + } + const blockCount = track.childElementCount; + for (let i = 0; i < blockCount; i++) { + track.appendChild(track.children[i].cloneNode(true)); + } + + const halfWidth = track.scrollWidth / 2; + const duration = Math.max(MIN_CYCLE_S, halfWidth / PX_PER_S); + track.style.setProperty('--ticker-duration', `${duration}s`); + } + + function appendEntry(text) { + const span = document.createElement('span'); + span.className = 'shell-ticker-entry'; + span.textContent = text; // textContent — never innerHTML (XSS) + track.appendChild(span); + } + + /** + * Map an activity event to a dispatch-voice line, or null for events that + * are workshop noise rather than ticker material (tool activity, text). + */ + function formatDispatchLine(event) { + const type = (event.type || '').toLowerCase(); + switch (type) { + case 'task.status_changed': + return `task ${shortEventId(event.task_id)} moved to ${event.to || 'unknown'} · stop`; + case 'task.created': + return `task ${shortEventId(event.task_id)} logged · stop`; + case 'workflow.run_started': + return `run ${shortEventId(event.run_id)} started — M`; + case 'workflow.run_completed': + return `run ${shortEventId(event.run_id)} completed — M`; + case 'workflow.run_failed': + return `run ${shortEventId(event.run_id)} failed · stop`; + case 'workflow.run_cancelled': + return `run ${shortEventId(event.run_id)} cancelled · stop`; + default: + return null; + } + } + + /** Unlike runs.js shortRunId (returns '' for blanks, never truncates), + * this shows an em-dash placeholder and caps at 8 chars — feed lines + * must stay fixed-width-ish and never render an empty gap. */ + function shortEventId(id) { + if (!id) return '—'; + return String(id).replace(/^(wr_|t_)/, '').slice(0, 8); + } + + return { init, ingest, render }; +})(); From 1aac280d09278724f358f63178f451008b5c885d Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Fri, 17 Jul 2026 17:18:14 +0300 Subject: [PATCH 12/16] feat(server): mothership ticker feed derived from instance polling --- .../Pages/Shared/_LayoutDashboard.cshtml | 6 +- .../src/Dotbot.Server/wwwroot/js/dashboard.js | 122 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_LayoutDashboard.cshtml b/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_LayoutDashboard.cshtml index 07edec10..0718d517 100644 --- a/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_LayoutDashboard.cshtml +++ b/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_LayoutDashboard.cshtml @@ -40,8 +40,12 @@ -