{% extends "scitex_ui/standalone_shell.html" %} {% load static %} {% comment %} Board v3 now extends the scitex-ui workspace shell (operator-asked, TG msg 302 / 303 — wants Alt+I element-inspector + shared chrome parity with the legacy GraphView). All inline content is repacked into the shell's three blocks: extra_css holds the page-local styles, app_content holds the kanban + BLOCKING-YOU panel + detail modal, extra_js holds the page logic. Alt+I element-inspector wires up automatically via scitex-ui's context_processor (settings.py guards the install at scitex-ui ≥0.5.0). For older scitex-ui installs the inspector degrades silently; the board itself works either way. The favicon link is injected via extra_css (the shell template has no favicon block; extra_css renders inside ). {% endcomment %} {% block extra_css %} {# 08-time-grouping.css: bucket headers TODAY/WEEK/MONTH/OLDER for Group-by-time (a2a `ff1441d7`) #} {# 09-timeline.css: Timeline layout — raster + per-task simple cards (operator TODO 2026-06-17) #} {# 11-sticky-wall.css: Wall layout (notes + islands) + the shared .stx-flash status-transition glow #} {# combobox.css (loaded below the page-CSS so its specificity sits ON TOP if needed) #} {% comment %} searchQuery.js — GitHub-style qualifier-syntax parser (project:/agent:/ status:/...). Exposes window.STX.searchQuery; the page logic below delegates `fuzzyMatch` to it whenever the input contains a recognized qualifier. Operator TG 12315/12316: photographed typing `project: paper-scitex-clew` into the search bar — they already expected GitHub-style syntax. Pure JS module, no DOM deps; unit tests at tests/scitex_todo/test__search_query.js. NB: Django's `{# … #}` syntax is single-line only — a multi-line block leaks its body into the rendered HTML. Use `{% comment %}` for any multi-line note inside a template (board v0.5.4 fix — PR #105). {% endcomment %} {% comment %} searchSuggest.js — autocomplete/Tab-completion engine, extends PR #102. Pure module; the inline script in extra_js below wires it to the #f-search input. Exposes window.STX.searchSuggest. Operator TG 12318. {% endcomment %} {% comment %} timelinePack.js — pure beeswarm sub-row packer for the Timeline raster. MUST load BEFORE timeline.js: it publishes window.STX.timelinePack, which timeline.js's IIFE captures at init so co-located markers fan out into sub-rows instead of occluding each other. No DOM; also node-testable. {% endcomment %} {% comment %} timelineGeo.js — pure time→pixel geometry (ms / barGeo / makeTicks / relTime) for the Timeline raster. MUST load BEFORE timeline.js: publishes window.STX.timelineGeo, captured by timeline.js's IIFE at init. No DOM; node-testable. Factored out so timeline.js stays under the per-file cap. {% endcomment %} {% comment %} 10-agent-avatar.js — per-agent lane avatar (fleet brand colour/label, or deterministic hash-hue + initials fallback) + status ring for the Timeline raster. MUST load BEFORE timeline.js: publishes window.STX.agentAvatar, captured like _pack/_geo; timeline.js falls back to plain-text lane labels when absent. Operator 2026-07-10 (todo-board-agent-icons-status-rings). {% endcomment %} {% comment %} 11-sticky-wall.js — the "sticky note wall" layout: notes readable without hover, clustered by assignee/project/status, and each agent's NEXT-UP stack derived here from nodes+depends_on edges (no agent cooperation required). Loads AFTER 10-agent-avatar.js (it reuses window.STX.agentAvatar for the note avatar) and publishes window.STX.stickyWall; render() falls back to the column layout when absent. Operator design, card todo-board-sticky-wall-view-20260710. {% endcomment %} {% comment %} 12-hover-tip.js — cursor-OFFSET hover tooltip for any [data-tip] element. Replaces the browser's native tooltip (SVG / title=""), which renders under the cursor and covers the dot or note being pointed at (operator msg 944). Self-attaches on DOMContentLoaded; one delegated listener, so every current and future view inherits it. {% endcomment %} <script defer src="{% static 'scitex_todo/board_v3/12-hover-tip.js' %}"></script> {% comment %} timelineGate.js — anti-flash change-detection gate for the Timeline raster. MUST load BEFORE timeline.js: publishes window.STX.timelineGate (rasterSig + a stateful gate that skips identical 4s rebuilds and preserves .tl-scroll scroll across a real rebuild), so the raster stops flashing / jumping to top. {% endcomment %} <script defer src="{% static 'scitex_todo/board_v3/timelineGate.js' %}"></script> {% comment %} timelineSelect.js — marker multi-select + copy-contents + right-click menu for the Timeline raster (operator 2026-06-26). Classic <script defer>; wires itself via event delegation on #columns so timeline.js (at its line cap) needs no change. Publishes window.STX.timelineSelect (pure formatCardCopy / joinCopyBlocks helpers, node-testable). (hook-bypass: line-limit.) {% endcomment %} <script defer src="{% static 'scitex_todo/board_v3/timelineSelect.js' %}"></script> {% comment %} timelineControls.js — Timeline controls-row builder (incl. the dependency- edge legend) + the edge hover-highlight. MUST load BEFORE timeline.js: publishes window.STX.timelineControls.controlsHtml (captured by timeline.js at init, like timelineGeo) and self-wires the hover delegation on #columns (mouseover/mouseout) so it survives the raster's auto-rebuilds without timeline.js re-attaching. Pure controlsHtml/edgeLegendHtml are node-testable. Factored out so timeline.js stays under the per-file cap. {% endcomment %} <script defer src="{% static 'scitex_todo/board_v3/timelineControls.js' %}"></script> {% comment %} timeline.js — the Timeline layout renderer (raster by agent/project + a rich per-task "simple" list). Classic <script defer> so it shares the page globals (STATE / escapeHtml / openDetail / bucket / render); it exposes its entry points on window for the inline render() dispatch. Operator TODO 2026-06-17. {% endcomment %} <script defer src="{% static 'scitex_todo/board_v3/timeline.js' %}"></script> <style> /* P12 (operator TG, lead a2a 2026-06-12) — board reclaims the left IDE-style panes. The scitex-ui standalone shell renders an AI console pane + a worktree file tree + a media viewer to the LEFT of the module pane; the kanban board doesn't need any of them, and the operator was photographed reporting "left empty space" eating the columns area. Override the shell defaults here so the kanban grid uses the full viewport width. The shell's resizer/toggle JS still no-ops cleanly when the targets are display:none. */ #workspace-three-col > .ws-ai-pane, #workspace-three-col > .ws-worktree-pane, #workspace-three-col > .ws-viewer-pane { display: none !important; } #workspace-three-col > .ws-module-pane, #workspace-three-col > #main-content.ws-module-pane { flex: 1 1 100%; max-width: 100vw; width: 100vw; } </style> {% comment %} FIRST-PAINT status-color CSS vars — the SINGLE source of truth for the board's per-status color layer (hook-bypass: line-limit). Server-rendered (deterministic, no FE flash) from `status_colors` (views.board_v3_page → handlers.graph._status_colors → _diagram.STATUS_STYLE). Cards, timeline, and the in-board mermaid all consume `--status-fill-<s>` / `--status-stroke-<s>` / `--status-border-<s>` so the 7 statuses render distinct + consistent, matching the python `build_mermaid()` artifacts. The 4 buckets still drive COLUMN placement + the blocked pulse — only the color layer is per-status. {% endcomment %} <style id="status-color-vars">:root{ {% for s, c in status_colors.items %} --status-fill-{{ s }}: {{ c.fill }}; --status-stroke-{{ s }}: {{ c.stroke }}; --status-border-{{ s }}: {% if c.dashed %}dashed{% else %}solid{% endif %}; {% endfor %}}</style> {% endblock extra_css %} {% block app_content %} {% comment %} Filterbar reorg (operator TG, lead a2a `b48f7c2c438b464698183d2e95d3bb04`, 2026-06-12). Three explicit groups end the flex-wrap chaos: .fb-left — identity (title + version + LIVE chip) .fb-center — search (input + autocomplete suggest) .fb-right — operations (Layout | Sort | Filters | Recent count | Group | Add Task | blocking me | overflow controls) Active-filter chips + qualifier hint pills move to a second row `<div class="filterbar-chips">` shown only when populated. {% endcomment %} <div class="filterbar"> <div class="fb-left"> <h1>scitex-todo <span class="small">v{{ scitex_todo_version|default:"?" }}</span></h1> <span class="small"><span class="live-dot"></span>LIVE</span> </div> <div class="fb-center stx-todo-filterbar__group stx-todo-filterbar__group--search"> <!-- SEARCH/FILTER group (board UI overhaul part 2, 2026-06-14): the search input + Filters popover share the center band so the operator's two query-narrowing tools live next to each other. Search-as-launcher (P1, lead brief 2026-06-12) — the PRIMARY filterbar entry point. --> <span class="filt-search-wrap" id="f-search-wrap"> <input class="filt filt-search" id="f-search" type="search" placeholder="🔎 Search… ( / to focus, Esc to blur) — try project:paper-scitex-clew, status:blocked, kind:compute, …" title="Fuzzy match + GitHub-style qualifiers (project: / agent: / status: / kind: / parent: / scope: / id: / priority: / host:). Press / to focus, Esc to blur. Tab to autocomplete the qualifier or value under the cursor." oninput="onSearchInput(event)" onkeydown="onSearchKeyDown(event)" onfocus="onSearchFocus(event)" onblur="onSearchBlur(event)" role="combobox" aria-autocomplete="list" aria-controls="search-suggest" aria-expanded="false" autocomplete="off"> <!-- Autocomplete dropdown (operator TG 12318) --> <ul class="search-suggest" id="search-suggest" role="listbox" aria-label="Search suggestions" hidden></ul> </span> <!-- P2 (lead brief 2026-06-12) — filter-UX collapse. Six filter dropdowns live inside a single "🔧 Filters (N active)" popover. Moved into the SEARCH/FILTER center group (board UI overhaul part 2) so query-narrowing controls cluster. --> <details id="filt-popover-wrap" class="filt-popover"> <summary id="filt-popover-summary" title="Open filter popover (project / host / status / blocker / agent / date)"> 🔧 Filters <span id="filt-active-count">(0)</span> ▾ </summary> <div class="filt-popover-body"> <div class="filt-popover-row"> <label for="f-project">Project</label> <select id="f-project"><option value="">All projects</option></select> </div> <div class="filt-popover-row"> <label for="f-host">Host</label> <select id="f-host"><option value="">All hosts</option></select> </div> <div class="filt-popover-row"> <label for="f-status">Status</label> <select id="f-status"><option value="">All statuses</option></select> </div> <div class="filt-popover-row"> <label for="f-blocker">Blocker</label> <select id="f-blocker"><option value="">All blockers</option></select> </div> <div class="filt-popover-row"> <label for="f-agent">Assignee</label> <select id="f-agent"><option value="">All assignees</option></select> </div> <div class="filt-popover-row"> <label for="f-date">Date</label> <select id="f-date" title="Filter by date proximity (operator TG 440/442)"> <option value="">All dates</option> <option value="overdue">📅 overdue</option> <option value="today">📅 today</option> <option value="7d">📅 within 7 days</option> <option value="30d">📅 within 30 days</option> <option value="undated">no date in title</option> </select> </div> <!-- Show-done toggle (operator direct request, lead a2a `b9d135c1ea8348dea80f027ba66a0079` 2026-06-12). DEFAULT OFF — `status: done` cards are hidden from every layout (Column / Graph / Table / Calendar). Persisted in localStorage["scitex-todo:show-done"]. The hidden-done count surfaces in the popover row label so the operator can't think "where did my done cards go". --> <div class="filt-popover-row filt-popover-row--toggle"> <label for="f-showdone" class="filt-popover-toggle-label"> <input type="checkbox" id="f-showdone" onchange="onShowDoneChange(event)"> <span>Show <code>done</code> tasks <span id="f-showdone-count" class="filt-popover-count">(0 hidden)</span></span> </label> </div> <div class="filt-popover-actions"> <button type="button" class="filt-popover-clear" onclick="clearAllFilters()" title="Reset all filters in this popover">Clear all</button> </div> </div> </details> </div>{# end .fb-center / SEARCH-FILTER group #} <div class="fb-right"> {% comment %} board UI overhaul part 2 (2026-06-14, operator complaint via lead a2a `d1af161e`) — explicit subgroups in the right band so the controls stop visually colliding: VIEW = Layout + Sort + Group STATUS = recent-count + Reload + hide-project + blocking-me + hidden PRIMARY = + Add Task (separated by a divider, brand-accent) The legacy `.fb-right` keeps its wrap-friendly flex; the new `.stx-todo-filterbar__group--*` wrappers add the breathing room + dividers. NB: this MUST be a `comment` block, not `{# #}` — Django's `{# #}` syntax is single-line only; newlines between `{#` and `#}` are NOT stripped, so the comment leaks as literal text onto the operator's screen. The leak shipped briefly in PR #173 (the header-declutter PR); fix-forward in this PR per lead a2a `f7a5d37930b9479ca7e53a7e316c132d`, 2026-06-14, after the operator reported it live. {% endcomment %} <div class="stx-todo-filterbar__group stx-todo-filterbar__group--view" role="group" aria-label="View controls — layout, sort, group"> <!-- P9 (lead brief 2026-06-12) — sort-by control. The headline use is "sort by deadline" (pairs with the P4 deadline schema landing later). For now the operator can sort by deadline (parsed from title), priority rank, status, project, last-activity. --> <!-- LAYOUT AXIS (lead design ruling 2026-06-12, operator TG 12461) — the board renders the SAME data in one of three orthogonal layouts. Column is the kanban kept from v0.5.5; Table is a flat sortable rows view; Graph is the dependency / blocker graph (mermaid SVG, fed from /graph). Recent is NOT a layout — it's a sort mode below that applies across all layouts. --> <!-- Order: Timeline FIRST (operator 2026-07-10, card todo-board-remove-stale-view-timeline-first-20260710 — "Timeline view 本丸なので一番左に"). The Stale layout button was removed on the same card ("Stale view 要らない"); its render path + /stale endpoint stay (shared code), only the layout is unreachable. Default view is still Column (the --on button) — changing the default is an OPEN question on the card, not decided. --> <span class="filt-layout" title="Switch layout: Timeline (time raster) | Graph (dep map) | Column (kanban) | Table (rows)"> <label class="filt-layout-label">Layout</label> <div class="filt-layout-seg" role="radiogroup" aria-label="Layout mode"> <button type="button" class="filt-layout-btn" id="f-layout-timeline" data-layout="timeline" onclick="onLayoutChange('timeline')" role="radio" aria-checked="false" title="Timeline — time raster by agent/project, or a per-task simple list (day/week/month windows)">⏱ Timeline</button> <button type="button" class="filt-layout-btn" id="f-layout-wall" data-layout="wall" onclick="onLayoutChange('wall')" role="radio" aria-checked="false" title="Wall — sticky notes clustered by assignee; the selected agent's next-up stack rises to the top">🗒 Wall</button> <button type="button" class="filt-layout-btn" id="f-layout-graph" data-layout="graph" onclick="onLayoutChange('graph')" role="radio" aria-checked="false" title="Dependency / blocker graph (mermaid)">📊 Graph</button> <button type="button" class="filt-layout-btn filt-layout-btn--on" id="f-layout-column" data-layout="column" onclick="onLayoutChange('column')" role="radio" aria-checked="true" title="Project columns (kanban) — default">📋 Column</button> <button type="button" class="filt-layout-btn" id="f-layout-table" data-layout="table" onclick="onLayoutChange('table')" role="radio" aria-checked="false" title="Flat rows view">📑 Table</button> </div> </span> <span class="filt-sort"> <label for="f-sort">Sort</label> <select id="f-sort" title="Sort cards within each column (Recent applies across all layouts and adds NEW badges to <24h cards). Time-based sorts (last activity / created / completed) honor the operator's TIME-BASED VIEW request — lead a2a `ff1441d7`, 2026-06-14." onchange="onSortChange(event)"> <option value="default">default (date-aware)</option> <option value="recent">Recent (newest first) 🆕</option> <option value="deadline">deadline (soonest first)</option> <option value="priority">priority rank (#1 first)</option> <option value="status">status</option> <option value="project">project name</option> <option value="last_activity">last activity (newest)</option> <option value="created_at">created (newest first)</option> <option value="completed_at">completed (newest first)</option> <option value="title">title (A→Z)</option> </select> </span> {% comment %} Group-by-time toggle (operator-asked TIME-BASED VIEW — lead a2a `ff1441d7`, 2026-06-14). When ON, the column layout walks each project's visible cards and inserts collapsible bucket headers (TODAY / THIS WEEK / THIS MONTH / OLDER) keyed on `last_activity`. Default OFF — back-compat with the existing flat column view. State persists in localStorage["scitex-todo:group-by-time"]. Lives in the existing VIEW group next to Sort so the operator's time controls cluster (board UI overhaul part 2, PR #173). {% endcomment %} <span class="filt-groupby-time"> <label class="stx-todo-group-by-time-label" title="Group cards by last_activity bucket (TODAY / WEEK / MONTH / OLDER) within each column"> <input type="checkbox" id="stx-toggle-group-by-time" onchange="onGroupByTimeChange(event)"> <span>Group by time</span> </label> </span> <!-- P10 (lead a2a 2026-06-12) — group-by toggle. Off = today's flat column view. On = columns cluster under group headers (define clusters via the new `groups:` top-level in tasks.yaml). Empty when the store has no `groups:` (back-compat). --> <span class="filt-groupby"> <label for="f-groupby">Group</label> <select id="f-groupby" title="Cluster columns by project group" onchange="onGroupByChange(event)"> <option value="off">off (flat)</option> <option value="on">by project group</option> </select> </span> </div>{# end VIEW group #} <span class="stx-todo-filterbar__divider" aria-hidden="true"></span> <div class="stx-todo-filterbar__group stx-todo-filterbar__group--status" role="group" aria-label="Status controls — new-task badge, reload, visibility"> <!-- 🆕 N new in 24h — visible counter that lights up regardless of the selected sort. Operator TG 12461 (parity with /legacy's Recent tab) — surfaces "how many came in since yesterday" at a glance. Click to set Sort = Recent. --> <span class="filt-recent-count" id="recent-count-pill" title="Click to switch Sort to Recent (newest first)" onclick="setSortRecent()" style="display:none"> <span id="recent-count-value">0</span> new/24h </span> <button id="reload" class="reload-btn" onclick="loadGraph()">Reload</button> <!-- Project-hide — per-project COLUMN hide (operator TG 344 "プロジェクトの crud/hide"). Soft, client-side, localStorage- backed; mirror the per-card hide pattern below so hidden projects survive reload. --> <details id="proj-hide-wrap" class="proj-hide"> <summary id="proj-hide-summary" title="Hide entire project columns from view"> 🙈 hide project (<span id="proj-hidden-count">0</span>) </summary> <div id="proj-hide-list" class="proj-hide-list"> <div class="proj-hide-empty">No projects loaded yet.</div> </div> </details> <button id="t-block" class="toggle-block" onclick="toggleBlockingMe(event)"> 🚧 blocking me </button> <!-- Hidden-cards dropdown (operator TG 381): replaces the simple "show hidden" toggle. Lists every hidden card by title + project with a one-click un-hide button. Last row is the "show hidden in place" toggle (the legacy behavior) for users who'd rather reveal everything than un-hide one by one. --> <details id="hidden-wrap" class="hidden-list"> <summary id="hidden-summary" title="Hidden cards — click to expand"> 👁 hidden (<span id="hidden-count">0</span>) </summary> <div id="hidden-list-panel" class="hidden-list-panel"> <div class="hidden-list-empty">No hidden cards.</div> </div> </details> </div>{# end STATUS group #} <span class="stx-todo-filterbar__divider" aria-hidden="true"></span> <div class="stx-todo-filterbar__primary" role="group" aria-label="Primary action — add a new task"> <!-- Add Task — operator-CRUD wiring (TG 344): one-click new-task entry from the filterbar. Opens the #add-task-backdrop modal below; POST /create on submit; loadGraph() refresh. Promoted to the right-most "PRIMARY ACTION" zone (board UI overhaul part 2, 2026-06-14) so the operator's most-used button pops with the brand accent. --> <button id="add-task-btn" class="add-task-btn" onclick="openAddTaskModal()" title="Add a new task (Cmd/Ctrl-N)">+ Add Task</button> </div>{# end PRIMARY action zone #} </div>{# end .fb-right #} </div>{# end .filterbar #} {% comment %} Filterbar second row — active filter chips + qualifier-hint pills. Hidden via CSS when both children are empty so the main filterbar stays at ~50 px on the default state. Populated by renderActiveFilterChips() / renderQualifierHints() in extra_js. {% endcomment %} <div class="filterbar-chips" id="filterbar-chips"> <div class="filt-qhints" id="filt-qhints" aria-live="polite" aria-label="Recognized search qualifiers"></div> <div id="filt-chips" class="filt-chips" aria-live="polite" aria-label="Active filters"></div> </div> {% comment %} STATUS COLOR LEGEND (operator: "add color legends") — decodes the per-status colors used by cards / timeline / mermaid. SINGLE-SOURCED: iterates the SAME `status_colors` template context (views.board_v3_page → handlers.graph._status_colors → _diagram.STATUS_STYLE) in STATUS_STYLE order, so adding/removing a status updates the legend automatically — no hardcoded status list or hex colors here. Each chip references the first-paint `--status-fill-<s>` / `--status-stroke-<s>` / `--status-border-<s>` CSS vars (rendered by #status-color-vars) so the swatch matches the cards exactly, including `deferred`'s dashed border. `data-legend-status` per chip keeps the test robust. {% endcomment %} <div class="status-legend" id="status-legend" role="group" aria-label="Status color legend"> <span class="status-legend-title" aria-hidden="true">Legend</span> {% for s, c in status_colors.items %} <span class="status-legend-chip" data-legend-status="{{ s }}" title="{{ s }}" aria-label="status color: {{ s }}"> <span class="status-legend-swatch" aria-hidden="true" style="background: var(--status-fill-{{ s }}); border: 1px var(--status-border-{{ s }}, solid) var(--status-stroke-{{ s }});"></span> <span class="status-legend-label">{{ s }}</span> </span> {% endfor %} </div> {% comment %} BLOCKING-YOU 360 px aside REMOVED (P12, lead a2a 2026-06-12). The "right-end giant lane" the operator photographed is now a normal project column synthesized client-side as the `user` lane (see USER_LANE_KEY in _renderColumnView): every task with `status === "blocked" && blocker === "operator-decision"` shows up in a column titled `user` at normal width, with normal drag-reorder / pin / column-context-menu. Tasks still live in their REAL project column too; the user lane is a render-time aggregation, not a schema change. The mobile `#by-fab` drawer toggle is removed with the aside; the "🚧 blocking me" filterbar toggle (#t-block) still works because it's a filter, not a drawer. NB: PR #108 inadvertently left the aside HTML + `#block-rows` placeholder in place while removing the JS that populated it — operator saw "loading…" forever in the right sidebar. This commit finishes the job. {% endcomment %} <div class="main"> <!-- Multi-select board toolbar (PR(h) Stage 1, board card todo-multiselect-batch-ops, lead a2a 1ebc792c). Always rendered but visually quiet at zero selection (the Status dropdown is disabled until at least one card is checked). Bulk status change is the v1 op; the other 4 bulk ops (project re-assign / agent re-assign / bulk nudge / bulk hide) are follow-up PRs. (hook-bypass: line-limit.) --> <div id="board-toolbar" class="board-toolbar" role="region" aria-label="Multi-select bulk actions"> <label class="board-toolbar-all"> <input type="checkbox" id="board-toolbar-select-all" aria-label="Select or clear all visible cards" onchange="toggleSelectAll(this.checked)" /> select all </label> <span class="board-toolbar-sep">·</span> <span class="board-toolbar-count-label">selected: <span id="board-toolbar-count">0</span> </span> <span class="board-toolbar-sep">·</span> <label for="board-toolbar-status" class="board-toolbar-status-label">Status</label> <select id="board-toolbar-status" title="Set status on every selected card" aria-label="Bulk status change" disabled onchange="bulkSetStatus(this.value); this.value='';"> <option value="">— change to… —</option> {# `pending` abolished 2026-07-10; writing it now fails validation. #} <option value="in_progress">in_progress</option> <option value="blocked">blocked</option> <option value="done">done</option> <option value="deferred">deferred</option> <option value="failed">failed</option> <option value="goal">goal</option> </select> <button type="button" id="board-toolbar-clear" class="board-toolbar-clear" disabled onclick="clearMultiselect()">clear</button> <!-- TODO(PR-h+1): project-reassign / agent-reassign / nudge / hide. --> </div> <!-- P10 (lead a2a 2026-06-12) — spans_all groups (e.g. "lead") render as a thin banner here, above the columns grid. Empty by default (no groups, or no spans_all groups). --> <div id="group-spans-all" class="group-strip" style="display:none"></div> <!-- Empty-state banner (operator TG12911, lead a2a `ffa6c606`) — when the active filters narrow the visible set to 0 cards, surface an explicit "0 matches" line + a one-click "Clear filters" so a silent empty board doesn't read as "the filter is broken". Hidden by default; render() flips it on/off. --> <div id="filter-empty-banner" style="display:none; padding:14px 18px; margin:6px 12px; background:var(--accent-soft, #fff7e6); border:1px solid var(--accent, #d99700); border-radius:6px; font-size:13px; line-height:1.5;" role="status"> <strong style="color:var(--accent-strong, #b97400)">0 matches.</strong> <span id="filter-empty-banner-detail"></span> <button type="button" id="filter-empty-banner-clear" style="margin-left:10px; cursor:pointer;">Clear all filters</button> </div> <div class="columns" id="columns"> <div class="loading">loading…</div> </div> </div> <div class="foot"> <span>v3 LIVE · real data · operator schema (ADR-0007) · GUI→code (ADR-0006)</span> <span><code id="store-path">…</code></span> </div> <div class="detail-backdrop" id="detail-backdrop" onclick="closeDetail(event)"> <div class="detail" onclick="event.stopPropagation()"> <h2 id="d-title"></h2> <div class="detail-meta" id="d-meta"></div> <div id="d-rows"></div> <!-- Word-style threaded comments (operator's TG 9763 mental model per lead a2a a7e53184). All comments[] entries render here; Post appends via /comment + refreshes. --> <div class="d-comments-section"> <h3>🛤 Route <span class="route-hint">— how this card was handled (newest last)</span></h3> <div class="comment-list" id="d-comment-list"> <div class="comment-empty">…</div> </div> <div class="comment-add"> <textarea id="d-comment-text" placeholder="Add a comment… (Cmd/Ctrl-Enter to post)" rows="3"></textarea> <div class="comment-add-actions"> <span class="comment-add-hint">Posts to comments[]; visible to the owning agent.</span> <button class="comment-post-btn" id="d-comment-post" onclick="postCommentFromDrawer()">Post</button> </div> </div> </div> <!-- Inline edit form (toggled by the Edit button below). The form mirrors the Add-Task modal fields so the operator can change title / status / blocker / note / project / agent / task. POST goes to /update; success reloads via loadGraph(). --> <div class="detail-edit-form" id="d-edit-form"> <div class="at-row"> <label for="d-edit-title">Title</label> <input class="d-edit-input" id="d-edit-title" type="text"> </div> <div class="at-row" style="flex-direction: row; gap: 10px;"> <div style="flex: 1;"> <label for="d-edit-status">Status</label> <select class="d-edit-select" id="d-edit-status"> {# `pending` abolished 2026-07-10; writing it now fails validation. #} <option value="in_progress">in_progress</option> <option value="blocked">blocked</option> <option value="done">done</option> <option value="deferred">deferred</option> <option value="failed">failed</option> <option value="goal">goal</option> </select> </div> <div style="flex: 1;"> <label for="d-edit-blocker">Blocker</label> <select class="d-edit-select" id="d-edit-blocker"> <option value="">(none)</option> <option value="compute">compute</option> <option value="quota">quota</option> <option value="user-pending">user-pending</option> <option value="task-dependency">task-dependency</option> <option value="operator-decision">operator-decision</option> </select> </div> </div> <div class="at-row" style="flex-direction: row; gap: 10px;"> <div style="flex: 1;"> <label for="d-edit-project">Project</label> <input class="d-edit-input" id="d-edit-project" type="text"> </div> <div style="flex: 1;"> <label for="d-edit-agent">Assignee</label> <input class="d-edit-input" id="d-edit-agent" type="text"> </div> </div> <div class="at-row"> <label for="d-edit-task">Task (one-line current focus)</label> <input class="d-edit-input" id="d-edit-task" type="text"> </div> <div class="at-row"> <label for="d-edit-note">Note (markdown body)</label> <textarea class="d-edit-textarea" id="d-edit-note"></textarea> </div> <div class="d-edit-actions"> <button class="d-edit-cancel" onclick="cancelDetailEdit()">Cancel</button> <button class="d-edit-save" onclick="saveDetailEdit()">Save</button> </div> </div> <div class="detail-actions"> <button class="detail-close" onclick="closeDetail()">Close</button> <button class="detail-edit" id="d-edit" onclick="enterDetailEdit()">Edit</button> <button class="detail-delete" id="d-delete" onclick="deleteCurrentTask()">Delete</button> <button class="detail-cancel-resolve" id="d-cancel-resolve" onclick="disarmResolve()" style="display: none;">Cancel</button> <button class="detail-resolve" id="d-resolve" onclick="resolveTask()">Resolve → notify agent</button> </div> </div> </div> <!-- Add Task modal (operator TG 344 — create from browser). POST /create, reload board, close modal. project picker pre-fills from existing projects via the auto-discovered list. --> <div class="at-backdrop" id="add-task-backdrop" onclick="closeAddTaskModal(event)"> <div class="at-modal" onclick="event.stopPropagation()"> <h2>+ Add Task</h2> <div class="at-row"> <label for="at-title">Title <span style="color: var(--red);">*</span></label> <input class="at-input" id="at-title" type="text" autocomplete="off" placeholder="What needs to get done?"> </div> <!-- Assignee is REQUIRED (operator constitution, no silent fallbacks): a card must have an owner. The server (handle_create -> add_task) also rejects an owner-less create, but require it client-side so the user gets an inline message before the round-trip. hook-bypass: line-limit --> <div class="at-row"> <label for="at-assignee">Assignee <span style="color: var(--red);">*</span></label> <input class="at-input" id="at-assignee" name="task-assignee" type="text" autocomplete="off" required list="at-assignee-list" placeholder="Who owns this card? (e.g. operator)"> <datalist id="at-assignee-list"></datalist> </div> <div class="at-row" style="flex-direction: row; gap: 10px;"> <div style="flex: 1;"> <label for="at-project">Project</label> <input class="at-input" id="at-project" list="at-project-list" type="text" placeholder="e.g. scitex-todo"> <datalist id="at-project-list"></datalist> </div> <div style="flex: 1;"> <label for="at-status">Status</label> <select class="at-select" id="at-status"> {# `pending` abolished 2026-07-10 — the honest default is `deferred`. #} <option value="deferred">deferred</option> <option value="in_progress">in_progress</option> <option value="blocked">blocked</option> <option value="goal">goal</option> </select> </div> </div> <div class="at-row"> <label for="at-task">Task (one-line current focus) — optional</label> <input class="at-input" id="at-task" type="text" placeholder="What's the current step? (shows on the card)"> </div> <div class="at-actions"> <button class="at-btn at-btn-cancel" onclick="closeAddTaskModal()">Cancel</button> <button class="at-btn at-btn-create" id="at-create" onclick="submitAddTask()">Create</button> </div> </div> </div> <div class="toast" id="toast"></div> {% endblock app_content %} {% block extra_js %} <script> // Status mapping — the 7-value enum on the wire maps to 4 BUCKETS used // ONLY for COLUMN placement + sort order + the blocked pulse animation. // The per-status COLOR layer no longer uses these buckets: cards / // timeline / mermaid read the SSOT --status-* CSS vars (from // STATUS_STYLE via _status_colors) so all 7 statuses render distinct. // (hook-bypass: line-limit.) const STATUS_BUCKET = { working: "working", in_progress: "working", goal: "working", pending: "waiting", waiting: "waiting", deferred: "waiting", blocked: "blocked", done: "done", failed: "done", }; function bucket(status) { return STATUS_BUCKET[status] || "waiting"; } // Owner SSOT (client mirror of scitex_todo._owner.card_owner) — the // SINGLE rule for "who owns this card": `agent` falling back to // `assignee`, else null (an owner-less card). Used for the card-render // owner line + the unassigned-lane bucketing so an assignee-only card // shows its real owner instead of a blank, and an owner-less card lands // in a clearly-labeled lane rather than a retired fallback identity. // (hook-bypass: line-limit — board_v3.html split still queued.) function cardOwner(t) { if (!t) return null; const o = ((t.agent || t.assignee || "") + "").trim(); return o || null; } // The lane key an owner-less card buckets under — a clearly-labeled // lane, NEVER a retired identity (operator mandate 2026-06-26). const UNASSIGNED_LANE = "(unassigned)"; let STATE = { graph: null, openId: null, lastMtime: null, filters: { project: "", host: "", status: "", blocker: "", agent: "", blockingMe: false, showHidden: false, // Show-done — DEFAULT OFF (operator direct, lead a2a // `b9d135c1ea8348dea80f027ba66a0079` 2026-06-12). // `status: done` cards drop from every layout unless // the popover toggle is flipped on. Sticky via // localStorage["scitex-todo:show-done"]. showDone: (function () { try { return localStorage.getItem("scitex-todo:show-done") === "1"; } catch { return false; } })(), search: "", date: "" }, // P9 (lead brief 2026-06-12): per-column card sort. "default" = legacy // date-aware sort; other values pick a single field comparator. // (hook-bypass: line-limit — see REFACTORING.md.) sort: "default", // P10 (lead a2a 2026-06-12): toggle for the group-clustering view. // Off by default — back-compat. When on, columns reorder to cluster // projects under their `groups[]` entry; spans_all groups (e.g. // "lead") render as a banner strip above the columns grid. groupBy: false, // P0 / lead design ruling (TG 12461, 2026-06-12) — LAYOUT axis. // Three orthogonal modes; persisted in localStorage so the // operator's last choice survives reloads. STATE.sort (incl. // "recent") is orthogonal — applies within whichever layout is // active. // "stale" is no longer a reachable layout (operator 2026-07-10, card // todo-board-remove-stale-view-timeline-first-20260710) — a stored // value from before the removal coerces to the kanban here. layout: (function () { const l = localStorage.getItem("scitex-todo:layout"); return (l === "stale" || !l) ? "column" : l; })() }; // Sticky STATE.sort across reloads — operator picks Recent once, // expects it to remember next time. (hook-bypass: line-limit.) try { const _sortStored = localStorage.getItem("scitex-todo:sort"); if (_sortStored) STATE.sort = _sortStored; } catch {} // === Group-by-time STATE + persistence ============================ // Operator-asked TIME-BASED VIEW (lead a2a `ff1441d7`, 2026-06-14). // When ON, _renderColumnHtml() inserts bucket headers (TODAY / WEEK / // MONTH / OLDER) keyed on `last_activity`. State persists in // localStorage so the operator's preference survives reloads. // Default OFF — back-compat with the existing flat column view. STATE.groupByTime = (function () { try { return localStorage.getItem("scitex-todo:group-by-time") === "1"; } catch { return false; } })(); // Collapsed-bucket state — per-bucket key ("today" / "week" / "month" // / "older"); persists in localStorage. Default = all expanded. STATE.timeBucketsCollapsed = (function () { try { const raw = localStorage.getItem("scitex-todo:time-buckets-collapsed"); return new Set(JSON.parse(raw || "[]")); } catch { return new Set(); } })(); // === Hide (per-user soft-hide, localStorage-backed) ==================== // Stored as an array of task ids under "scitex-todo:hidden". Per-user/per- // browser only — fine for the single-host MBA today; cross-device sync // would need a schema field, deferred. Operator TG 256. function hiddenSet() { try { const raw = localStorage.getItem("scitex-todo:hidden") || "[]"; return new Set(JSON.parse(raw)); } catch { return new Set(); } } function saveHidden(s) { localStorage.setItem("scitex-todo:hidden", JSON.stringify([...s])); } function hideTask(id) { const s = hiddenSet(); s.add(id); saveHidden(s); render(); toast(`✓ Hidden ${id}`); } function unhideTask(id) { const s = hiddenSet(); s.delete(id); saveHidden(s); render(); } function toggleShowHidden(e) { STATE.filters.showHidden = !STATE.filters.showHidden; e.currentTarget.classList.toggle("on", STATE.filters.showHidden); render(); } // Non-disruptive auto-refresh support (hook-bypass: line-limit). // The board polls /graph every 5s. Re-rendering the whole board on // every tick made the view FLASH and JUMP TO THE TOP, losing the // operator's scroll position while reading lower content. Two guards: // 1. LAST_GRAPH_JSON — JSON.stringify of the last RENDERED graph. // When skipIfUnchanged is set and the fresh payload stringifies // identically, loadGraph() returns WITHOUT calling render(), so // the common "nothing changed" tick is a true no-op. // 2. captureScroll()/restoreScroll() — when a real change DOES force // a redraw, the scroll offsets of the page + the #columns canvas // (which itself scrolls in table/graph/mobile layouts) are // captured before render() and restored after it. let LAST_GRAPH_JSON = null; function _scrollEls() { // The page itself scrolls (document.scrollingElement) AND, in the // table / graph / responsive-mobile layouts, the #columns canvas // has its own overflow:auto. Restoring a 0 offset is a harmless // no-op, so we snapshot/restore both unconditionally. const els = []; const se = document.scrollingElement || document.documentElement; if (se) els.push(se); const cols = document.getElementById("columns"); if (cols) els.push(cols); return els; } function captureScroll() { return _scrollEls().map((el) => ({ el, top: el.scrollTop, left: el.scrollLeft, })); } function restoreScroll(snap) { if (!snap) return; for (const s of snap) { if (!s.el) continue; s.el.scrollTop = s.top; s.el.scrollLeft = s.left; } } async function loadGraph(opts) { const { preserveScroll = false, skipIfUnchanged = false } = opts || {}; try { const r = await fetch("/graph"); if (!r.ok) throw new Error(`HTTP ${r.status}`); STATE.graph = await r.json(); // Change-detection: if the fresh payload is byte-identical to the // last graph we rendered, skip the whole re-render (no flash, no // scroll jump). Only honored for the auto-refresh path so an // explicit/first loadGraph() always paints. const _freshJson = JSON.stringify(STATE.graph); if (skipIfUnchanged && _freshJson === LAST_GRAPH_JSON) { return; } const _scrollSnap = preserveScroll ? captureScroll() : null; document.getElementById("store-path").textContent = STATE.graph.store_path || "—"; populateFilters(); // Sync the filterbar UI to the persisted STATE on first paint: // - Sort dropdown reflects STATE.sort (incl. "recent") // - Layout segment buttons reflect STATE.layout // Lead design ruling 2026-06-12 — sticky across reloads. const _sortSel = document.getElementById("f-sort"); if (_sortSel && STATE.sort) _sortSel.value = STATE.sort; const _curLayout = STATE.layout || "column"; document.querySelectorAll(".filt-layout-btn").forEach(btn => { const active = btn.getAttribute("data-layout") === _curLayout; btn.classList.toggle("filt-layout-btn--on", active); btn.setAttribute("aria-checked", active ? "true" : "false"); }); // Sync the Show-done checkbox to the persisted STATE so the // popover reflects the actual filter state on first paint. const _sdBox = document.getElementById("f-showdone"); if (_sdBox) _sdBox.checked = !!STATE.filters.showDone; // Sync the Group-by-time checkbox (operator TIME-BASED VIEW, // lead a2a `ff1441d7`) to the persisted STATE so the toggle // reflects the actual state on first paint. const _gbtBox = document.getElementById("stx-toggle-group-by-time"); if (_gbtBox) _gbtBox.checked = !!STATE.groupByTime; render(); // Record what we just painted so the next auto-refresh tick can // detect "no change" and skip the redraw entirely. Restore the // scroll offsets captured before render() so a real change // redraws in place instead of snapping to the top. // (hook-bypass: line-limit.) LAST_GRAPH_JSON = _freshJson; restoreScroll(_scrollSnap); } catch (e) { document.getElementById("columns").innerHTML = `<div class="loading" style="color: var(--red);">load error: ${e.message}</div>`; } } function passes(t) { const f = STATE.filters; // Show-done — DEFAULT OFF (operator direct, lead a2a // `b9d135c1ea8348dea80f027ba66a0079`). `status: done` cards drop // from every layout unless the popover toggle is flipped on. // Done early so the rest of the predicates never see done rows. if (!f.showDone && t.status === "done") return false; if (f.project && (t.project || "Uncategorized") !== f.project) return false; if (f.host && (t.host || "") !== f.host) return false; if (f.status && t.status !== f.status) return false; // RAW 7-status filter (hook-bypass: line-limit) if (f.blocker) { if (f.blocker === "__none" && t.blocker) return false; if (f.blocker !== "__none" && t.blocker !== f.blocker) return false; } if (f.agent && (t.agent || "") !== f.agent) return false; if (f.blockingMe && !(t.status === "blocked" && t.blocker === "operator-decision")) return false; // Hide filter: by default hidden cards are dropped; the filter-bar // toggle flips f.showHidden and brings them back (greyed via CSS). if (!f.showHidden && hiddenSet().has(t.id)) return false; // Fuzzy search (operator TG 437 / 438): subsequence char match // across title + project + agent + note. Case-insensitive. if (f.search && !fuzzyMatch(f.search, t)) return false; // Date filter (operator TG 440 / 442): proximity buckets derived // from the next future date parsed out of the title. if (f.date) { const di = dateInfo(t); if (f.date === "undated" && di) return false; if (f.date === "overdue" && !(di && di.daysFromNow < 0)) return false; if (f.date === "today" && !(di && di.daysFromNow === 0)) return false; if (f.date === "7d" && !(di && di.daysFromNow >= 0 && di.daysFromNow <= 7)) return false; if (f.date === "30d" && !(di && di.daysFromNow >= 0 && di.daysFromNow <= 30)) return false; } return true; } // === Fuzzy search (operator TG 437/438) + qualifier syntax (TG 12315/16) // fzf-style subsequence (PR #80) for BARE tokens. When the query // contains a GitHub-style qualifier (project:foo, status:blocked, …) // the searchQuery.js parser takes over via window.STX.searchQuery; // qualifiers AND-combine + bare tokens still go through the fuzzy // haystack so nothing PR #80 shipped regresses. function fuzzyMatch(query, t) { const raw = String(query || ""); if (!raw.trim()) return true; const SQ = (typeof window !== "undefined" && window.STX && window.STX.searchQuery) || null; if (SQ && /[A-Za-z_][A-Za-z0-9_-]*:/.test(raw)) { // Delegate to the qualifier-aware matcher. Parse-once cache lives // on STATE so we don't re-tokenize per task. if (STATE._searchParsed == null || STATE._searchParsedRaw !== raw) { STATE._searchParsed = SQ.parseSearchQuery(raw); STATE._searchParsedRaw = raw; } return SQ.matchesSearchQuery(t, STATE._searchParsed); } // Legacy fast path — no qualifiers, just the PR #80 fzf-style match. const q = raw.toLowerCase().trim(); const hay = ( (t.title || "") + " " + (t.task || "") + " " + (t.project || "") + " " + (t.agent || "") + " " + (t.note || "") + " " + (t.id || "") ).toLowerCase(); let i = 0; for (const c of q) { if (c === " ") continue; // whitespace tolerant const found = hay.indexOf(c, i); if (found < 0) return false; i = found + 1; } return true; } // === Qualifier hint pills (operator TG 12315/12316) ==================== // Renders one pill per recognized qualifier above the search input. // Unknown qualifiers (typos) render grey with a "did you mean ..." // tooltip — don't block typing, just hint. Reuses the date-pill / // filter-chip visual pattern from PR #80. function renderQualifierHints(raw) { const wrap = document.getElementById("filt-qhints"); if (!wrap) return; const SQ = (typeof window !== "undefined" && window.STX && window.STX.searchQuery) || null; if (!SQ || !raw || !/[A-Za-z_][A-Za-z0-9_-]*:/.test(raw)) { wrap.innerHTML = ""; return; } const parsed = SQ.parseSearchQuery(raw); const pills = parsed.hints.map((h) => { const classes = ["filt-qhint"]; if (h.unknown) classes.push("filt-qhint--unknown"); if (h.unknownValue) classes.push("filt-qhint--unknown-value"); const tip = h.unknown ? `unknown qualifier — did you mean: ${h.suggestion}` : h.unknownValue ? `unknown value — try one of: ${h.suggestion}` : `filter on ${h.label}`; return `<span class="${classes.join(" ")}" title="${escapeHtml(tip)}">` + `<span class="filt-qhint-key">${escapeHtml(h.label)}:</span>` + `<span class="filt-qhint-val">${escapeHtml(h.value || "(empty)")}</span>` + `</span>`; }); wrap.innerHTML = pills.join(""); } function onSearchInput(e) { STATE.filters.search = e.currentTarget.value; // Invalidate the qualifier-parse cache on every keystroke. STATE._searchParsed = null; STATE._searchParsedRaw = null; renderQualifierHints(e.currentTarget.value); renderActiveFilterChips(); // P2: surface the search chip while typing renderSearchSuggest(e.currentTarget); render(); } // === Search autocomplete (operator TG 12318) =========================== // Inline-script wrapper around static/scitex_todo/board_v3/searchSuggest.js // (deferred load). Mirrors the React SearchAutocomplete.tsx component so // the vanilla board stays in lock-step with the React surface. Pure DOM // here — the suggestion math lives in searchSuggest.js / searchQuery.js. let _SEARCH_SUGG_ACTIVE = 0; let _SEARCH_SUGG_ITEMS = []; function _suggDataSource() { const nodes = (STATE.graph && STATE.graph.nodes) || []; return { nodes }; } function renderSearchSuggest(input) { const list = document.getElementById("search-suggest"); if (!list) return; const SS = (window.STX && window.STX.searchSuggest) || null; if (!SS) { list.hidden = true; input.setAttribute("aria-expanded", "false"); return; } const q = input.value; const pos = input.selectionStart != null ? input.selectionStart : q.length; const sugs = SS.computeSuggestions(q, pos, _suggDataSource()).slice(0, 8); _SEARCH_SUGG_ITEMS = sugs; if (_SEARCH_SUGG_ACTIVE >= sugs.length) _SEARCH_SUGG_ACTIVE = 0; if (!sugs.length) { list.innerHTML = ""; list.hidden = true; input.setAttribute("aria-expanded", "false"); input.removeAttribute("aria-activedescendant"); return; } list.hidden = false; input.setAttribute("aria-expanded", "true"); list.innerHTML = sugs.map((s, i) => { const f = SS.formatSuggestion(s); const cls = ["search-suggest__row", `search-suggest__row--${s.kind}`, i === _SEARCH_SUGG_ACTIVE ? "search-suggest__row--active" : ""] .filter(Boolean).join(" "); const badge = (s.count != null) ? `<span class="search-suggest__badge">${s.count}</span>` : ""; const hint = f.hint ? `<span class="search-suggest__hint">${escapeHtml(f.hint)}</span>` : ""; return `<li id="search-suggest-row-${i}" role="option" ` + `aria-selected="${i === _SEARCH_SUGG_ACTIVE}" class="${cls}" ` + `onmousedown="_searchSuggClick(event, ${i})" ` + `onmouseenter="_searchSuggHover(${i})">` + `<span class="search-suggest__label">${escapeHtml(f.label)}</span>` + badge + hint + `</li>`; }).join(""); input.setAttribute("aria-activedescendant", `search-suggest-row-${_SEARCH_SUGG_ACTIVE}`); } function _searchSuggHover(i) { _SEARCH_SUGG_ACTIVE = i; const list = document.getElementById("search-suggest"); if (!list) return; [...list.children].forEach((el, j) => { el.classList.toggle("search-suggest__row--active", j === i); el.setAttribute("aria-selected", j === i); }); const input = document.getElementById("f-search"); if (input) input.setAttribute("aria-activedescendant", `search-suggest-row-${i}`); } function _searchSuggClick(e, i) { // mousedown so the input doesn't blur first. e.preventDefault(); _SEARCH_SUGG_ACTIVE = i; _commitSearchSuggest(); } function _commitSearchSuggest() { const SS = (window.STX && window.STX.searchSuggest) || null; const input = document.getElementById("f-search"); if (!SS || !input) return false; const sug = _SEARCH_SUGG_ITEMS[_SEARCH_SUGG_ACTIVE]; if (!sug) return false; const pos = input.selectionStart != null ? input.selectionStart : input.value.length; const { newQuery, newCursorPos } = SS.applySuggestion(input.value, pos, sug); input.value = newQuery; try { input.setSelectionRange(newCursorPos, newCursorPos); } catch {} STATE.filters.search = newQuery; STATE._searchParsed = null; STATE._searchParsedRaw = null; renderQualifierHints(newQuery); renderActiveFilterChips(); renderSearchSuggest(input); render(); return true; } function onSearchKeyDown(e) { const list = document.getElementById("search-suggest"); const open = list && !list.hidden; if (!open) { if (e.key === "ArrowDown" || e.key === "Tab") { renderSearchSuggest(e.currentTarget); } return; } const n = _SEARCH_SUGG_ITEMS.length; if (e.key === "ArrowDown") { e.preventDefault(); _SEARCH_SUGG_ACTIVE = n ? (_SEARCH_SUGG_ACTIVE + 1) % n : 0; renderSearchSuggest(e.currentTarget); } else if (e.key === "ArrowUp") { e.preventDefault(); _SEARCH_SUGG_ACTIVE = n ? (_SEARCH_SUGG_ACTIVE - 1 + n) % n : 0; renderSearchSuggest(e.currentTarget); } else if (e.key === "Tab" || e.key === "Enter") { if (n > 0) { e.preventDefault(); _commitSearchSuggest(); } } else if (e.key === "Escape") { e.preventDefault(); list.hidden = true; e.currentTarget.setAttribute("aria-expanded", "false"); } } function onSearchFocus(e) { renderSearchSuggest(e.currentTarget); } function onSearchBlur(e) { // Defer close so a click on a row commits before the dropdown vanishes. setTimeout(() => { const list = document.getElementById("search-suggest"); if (list) list.hidden = true; e.target.setAttribute("aria-expanded", "false"); }, 150); } // === Date extraction + proximity color (operator TG 440/442) ===== // Pulls every YYYY-MM-DD pattern out of the title (also YYYY/MM/DD) // and picks the NEXT future date (or, if all dates are past, the // most recent past one). Renders an inline 📅 pill colored by // proximity: red ≤3d, amber ≤7d, blue ≤30d, plain otherwise. const _DATE_RX = /(20\d{2})[-\/](\d{1,2})[-\/](\d{1,2})/g; function _parseAllDates(title) { const out = []; let m; _DATE_RX.lastIndex = 0; while ((m = _DATE_RX.exec(title)) !== null) { const y = +m[1], mo = +m[2], d = +m[3]; const dt = new Date(y, mo - 1, d); if (!isNaN(dt) && dt.getMonth() === mo - 1) out.push(dt); } return out; } function dateInfo(t) { const now = new Date(); now.setHours(0, 0, 0, 0); // P4 (lead approved 2026-06-12) — prefer the SERVER-COMPUTED // `deadline_next` over the raw `deadline` over the title-parsed // date. The server expands recurring + multi forms before the wire, // so the FE never re-derives the next occurrence. Back-compat: // tasks without any of those fall through to the existing _DATE_RX // title scan. (hook-bypass: line-limit — see REFACTORING.md.) const next = t.deadline_next || t.deadline; if (next) { const dl = new Date(next); if (!isNaN(dl)) { dl.setHours(0, 0, 0, 0); const days = Math.round((dl - now) / 86400000); return { date: dl, daysFromNow: days, all: [dl], source: t.deadline_next ? "deadline_next" : "deadline", // Pass the recurring info forward so datePillHtml can append // "every Nu" — extracted lazily from the raw deadline. repeater: _extractRepeaterSuffix(t.deadline) || _extractRepeaterSuffix(_firstRecurringDeadline(t)), }; } } const title = (t.title || "") + " " + (t.task || ""); const dates = _parseAllDates(title); if (dates.length === 0) return null; const future = dates.filter(d => d >= now).sort((a, b) => a - b); const pick = future.length ? future[0] : dates.sort((a, b) => b - a)[0]; const days = Math.round((pick - now) / 86400000); return { date: pick, daysFromNow: days, all: dates, source: "title" }; } // P4 PR3: scan the raw deadline (or deadlines[]) for an org repeater // suffix like " +1w" / " ++2m" and return a human-readable form // ("every 1w") for the date-pill label. Returns null when absent. // (hook-bypass: line-limit.) function _extractRepeaterSuffix(rawDeadline) { if (!rawDeadline) return null; const m = String(rawDeadline).match(/\s\+\+?(\d+)([dwmy])$/); if (!m) return null; return `every ${m[1]}${m[2]}`; } function _firstRecurringDeadline(t) { const arr = Array.isArray(t.deadlines) ? t.deadlines : []; for (const e of arr) { if (typeof e === "string" && /\s\+\+?\d+[dwmy]$/.test(e)) { return e; } } return null; } function datePillHtml(t) { const di = dateInfo(t); if (!di) return ""; const d = di.daysFromNow; let cls = "date-pill", label; if (d < 0) { cls += " date-pill--overdue"; label = `📅 overdue ${-d}d`; } else if (d === 0) { cls += " date-pill--today"; label = "📅 today"; } else if (d <= 3) { cls += " date-pill--soon"; label = `📅 in ${d}d`; } else if (d <= 7) { cls += " date-pill--week"; label = `📅 in ${d}d`; } else if (d <= 30) { cls += " date-pill--month"; label = `📅 in ${d}d`; } else { cls += " date-pill--far"; label = `📅 ${di.date.toISOString().slice(0,10)}`; } // P4 PR3: append " · every Nu" to the label when this is a // recurring deadline (org repeater suffix), so the operator sees // both the next occurrence AND the cadence on the pill. if (di.repeater) label += ` · ${di.repeater}`; const iso = di.date.toISOString().slice(0, 10); return `<span class="${cls}" title="next date: ${iso} (${d>=0?'+':''}${d}d)${di.repeater ? ' ' + di.repeater : ''}">${label}</span>`; } // === Age pill (P12, operator-asked age display + stale color) ==== // Bucket cards by how long they have been on the board, derived from // `created_at` (preferred) or `last_activity` (fallback for the // ~80 % of legacy tasks without `created_at` set). Returns null when // neither field is present / parseable so the pill simply doesn't // render (back-compat: old data shows no pill, not "NaN"). // today : 0 d // fresh : 1-6 d // aging : 7-29 d // stale : 30-89 d // rotten : >= 90 d // CSS lives in board_v3/02-card.css (.age-pill + 5 modifier classes). function _ageDays(t) { const ts = t.created_at || t.last_activity; if (!ts) return null; const created = new Date(ts); if (isNaN(created)) return null; const days = Math.floor((Date.now() - created.getTime()) / 86400000); return days >= 0 ? days : null; } function agePillHtml(t) { const days = _ageDays(t); if (days == null) return ""; let cls = "age-pill", label = `${days}d`; if (days === 0) { cls += " age-pill--today"; label = "new"; } else if (days <= 6) { cls += " age-pill--fresh"; } else if (days <= 29) { cls += " age-pill--aging"; } else if (days <= 89) { cls += " age-pill--stale"; } else { cls += " age-pill--rotten"; } const src = t.created_at ? "created" : "last activity"; return `<span class="${cls}" title="${days}d since ${src}">⏳ ${label}</span>`; } // === Activity bucket badge (render side of working-status decay) ==== // Board card `scitex-todo-working-status-decay-tg12739`, render half // of PR #122 (backend `_build_fleet` already derives the same buckets // for the fleet strip). Replaces the misleading "manual working color // stays lit indefinitely" pain (operator TG 12739) on a PER-CARD basis: // a small dot badge in the card-top reflects how RECENTLY the task was // touched, derived from `last_activity` freshness — orthogonal to the // age-pill (which buckets in DAYS, this one buckets in HOURS). // // fresh : <= 1 h -- bright green (live activity) // warm : 1-24 h -- amber (recent but quieting) // stale : > 24 h -- muted grey (decayed) // // Tooltip explains the bucket + relative time. Renders nothing when // `last_activity` is absent / unparseable — back-compat with older // snapshots that pre-date the field. function _activityHoursSince(t) { const ts = t && t.last_activity; if (!ts) return null; const parsed = Date.parse(ts); if (isNaN(parsed)) return null; const h = (Date.now() - parsed) / 3600000; return h >= 0 ? h : null; } function _activityBucket(hours) { if (hours == null) return null; if (hours <= 1) return "fresh"; if (hours <= 24) return "warm"; return "stale"; } function _activityRelative(hours) { if (hours == null) return ""; if (hours < 1) return `${Math.max(1, Math.round(hours * 60))} min ago`; if (hours < 24) return `${Math.round(hours)} h ago`; const days = Math.floor(hours / 24); return `${days} d ago`; } function activityBadgeHtml(t) { const hours = _activityHoursSince(t); const bucket = _activityBucket(hours); if (!bucket) return ""; const rel = _activityRelative(hours); return `<span class="activity-badge activity-badge--${bucket}" title="activity bucket: ${bucket} (${rel})" aria-label="activity bucket ${bucket}, ${rel}">● ${bucket}</span>`; } function populateFilters() { if (!STATE.graph) return; const nodes = STATE.graph.nodes; const uniq = (key) => { const s = new Set(); for (const n of nodes) { const v = n[key]; if (v) s.add(v); } return [...s].sort(); }; const set = (id, items, label) => { const el = document.getElementById(id); const cur = el.value; el.innerHTML = `<option value="">${label}</option>` + items.map(i => `<option value="${i}">${i}</option>`).join(""); if (items.includes(cur)) el.value = cur; }; // f-project includes an explicit "Uncategorized" entry when any // task in the store lacks a `project` field (operator TG 383 — // makes the empty-project column filterable). const projects = uniq("project"); if (nodes.some(n => !n.project)) projects.push("Uncategorized"); set("f-project", projects, "All projects"); set("f-host", uniq("host"), "All hosts"); set("f-agent", uniq("agent"), "All assignees"); // Status / blocker = closed enums. Status now lists the 7 REAL // statuses (SSOT STATUS_STYLE order) and filters by RAW status — the // 4-bucket collapse was the source of the goal/failed color confusion. // (hook-bypass: line-limit.) document.getElementById("f-status").innerHTML = `<option value="">All statuses</option>` + ["goal", "done", "in_progress", "blocked", "deferred", "failed", "cancelled"] .map(s => `<option value="${s}">${s}</option>`).join(""); document.getElementById("f-blocker").innerHTML = `<option value="">All blockers</option>` + ["compute", "dependency", "dep", "operator-decision", "agent-wait", "none"] .map(b => `<option value="${b}">${b}</option>`).join("") + `<option value="__none">no blocker</option>`; // P11b rebind — refresh the scitex-ui Combobox layered over each // <select.filt> so its item list reflects the freshly-populated // option lists. No-op when window.STX.Combobox is unavailable. if (typeof attachCombobox === "function") attachCombobox(); } function escapeHtml(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">","\"":""","'":"'" }[c])); } function cardHtml(t) { const b = bucket(t.status); const blocker = t.blocker ? `<span class="card-blocker${t.blocker === "operator-decision" ? " card-blocker--operator-decision" : ""}">🚧 ${escapeHtml(t.blocker)}</span>` : ""; const pr = t.pr_url ? `<a class="card-link" href="${escapeHtml(t.pr_url)}" target="_blank" onclick="event.stopPropagation()">PR</a>` : ""; const issue = t.issue_url ? `<a class="card-link" href="${escapeHtml(t.issue_url)}" target="_blank" onclick="event.stopPropagation()">Issue</a>` : ""; const taskLine = escapeHtml(t.task || t.title); // fallback to title until `task` is populated everywhere const goal = t.goal ? `<div class="card-goal" title="${escapeHtml(t.goal)}">${escapeHtml(t.goal)}</div>` : ""; const host = t.host ? `<span class="card-host">${escapeHtml(t.host)}</span>` : "<span></span>"; const last = t.last_activity ? `last ${escapeHtml(t.last_activity)}` : ""; const id = escapeHtml(t.id); // Owner (SSOT cardOwner = agent||assignee) + creator (created_by, // falling back to the earliest comment author). An assignee-only card // now shows its REAL owner instead of a blank; an owner-less card is // flagged "⚠ unassigned" rather than silently blank. (hook-bypass: line-limit) const _owner = cardOwner(t); const _firstComment = (t.comments || []) .map(c => c && c.author).find(a => typeof a === "string" && a); const _creator = t.created_by || _firstComment || ""; const ownerLine = `<span class="card-owner" title="owner (assignee/agent)${_creator ? " · created by " + escapeHtml(_creator) : ""}">${ _owner ? "👤 " + escapeHtml(_owner) : '<span class="card-owner--unassigned">⚠ unassigned</span>' }${_creator ? ' <span class="card-creator">· by ' + escapeHtml(_creator) + "</span>" : ""}</span>`; // Priority ↑↓ on every card (operator TG 9776 / 276). Re-orders the // POSITION-IN-FULL-LIST, not just within this column — the existing // /priority handler reassigns ranks 1..N over the entire payload, so // a single swap with the global-order neighbor is what's persisted. const prio = ` <div class="card-priority" title="↑ bump priority up, ↓ down (operator TG 276)"> <button class="card-prio-btn" aria-label="Bump priority up" onclick="event.stopPropagation(); bumpPriority('${id}', -1)">▲</button> <button class="card-prio-btn" aria-label="Bump priority down" onclick="event.stopPropagation(); bumpPriority('${id}', 1)">▼</button> </div>`; const prioRank = (typeof t.priority === "number") ? `<span class="card-prio-rank${t.priority === 1 ? " card-prio-rank--top" : ""}" title="Priority rank">#${t.priority}</span>` : ""; // Hide button — soft-hide via localStorage (operator TG 256). Un-hide // is reached via the filter-bar "👁 show hidden (N)" toggle. const isHidden = hiddenSet().has(t.id); const hideBtn = isHidden ? `<button class="card-hide-btn" title="Un-hide this card" onclick="event.stopPropagation(); unhideTask('${id}')">Un-hide</button>` : `<button class="card-hide-btn" title="Hide this card from view (per-user local)" onclick="event.stopPropagation(); hideTask('${id}')">Hide</button>`; // Copy button — operator TG 375 "share/copy" feature. One click = // a Markdown-friendly one-liner is copied to the clipboard so the // operator (or any agent) can paste a card reference into chat, // GitHub, docs, etc. without typing out title/id/project/status. const copyBtn = `<button class="card-copy-btn" title="Copy this card as a one-line reference (title · id · project · status)" aria-label="Copy reference to clipboard" onclick="event.stopPropagation(); copyCardText('${id}')">📋</button>`; const hiddenCls = isHidden ? " card--hidden" : ""; // NEW badge — only when sort=recent AND the card has activity in // the last 24 h. Sits in card-top so it's the first thing the // operator sees on a card. (lead design ruling 2026-06-12 — the // badge is part of Recent's projection across all layouts.) const isRecentMode = (STATE.sort || "default") === "recent"; const newBadge = (isRecentMode && _isNew24h(t)) ? `<span class="card-new-badge" title="Activity in the last 24 h">NEW</span>` : ""; // Multi-select checkbox (PR(h) Stage 1, board card // todo-multiselect-batch-ops, lead a2a 1ebc792c). Per-row // checkbox surfaces selection without changing the click / // drag affordance — onclick.stopPropagation keeps a toggle // from bubbling up into openDetail(). Selection state is // CLIENT-SIDE ONLY, held in window.MULTISELECT (Set<string>); // not persisted across reloads (v2 concern). (hook-bypass: line-limit.) const isSel = (window.MULTISELECT && window.MULTISELECT.has(t.id)) || false; const selBox = `<input type="checkbox" class="card-select" data-task-id="${id}" ${isSel ? "checked" : ""} title="Select this card for bulk actions" aria-label="Select card ${id} for bulk actions" onclick="event.stopPropagation(); toggleCardSelected('${id}', this.checked)" />`; // `data-status` (raw 7-value enum) drives the per-status COLOR layer // (left-bar fill + status-label color) via the --status-* CSS vars; // `card--${b}` (4 buckets) still drives COLUMN placement + the blocked // pulse animation. (hook-bypass: line-limit.) return `<article class="card card--${b}${hiddenCls}" data-card-id="${id}" data-status="${escapeHtml(t.status || "")}" draggable="true" ondragstart="onCardDragStart(event,'${id}')" ondragend="onCardDragEnd(event)" onclick="openDetail('${id}')" oncontextmenu="openCardCtx(event,'${id}')"> <div class="card-pad"> <div class="card-top"> ${selBox} <span class="card-status">${escapeHtml(t.status || b)}</span> ${activityBadgeHtml(t)} ${newBadge} <span class="card-last">${last}</span> ${agePillHtml(t)} ${prioRank} ${prio} </div> ${goal} <div class="card-task">${taskLine}</div> ${datePillHtml(t)} <div class="card-owner-line">${ownerLine}</div><!-- owner+creator (hook-bypass: line-limit) --> <div class="card-bot">${host}<span style="display:flex;gap:4px;align-items:center;">${pr}${issue}${copyBtn}${hideBtn}</span></div> ${blocker} </div> </article>`; } // === Right-click context menu — CRUD on a card (operator TG 432/433) ===== // Replaces the heavyweight "open detail drawer + Edit/Delete buttons" with // a one-right-click menu so common ops are reachable without leaving the // board. Wired to existing /update + /delete + /restore + hide / // copy / move-project handlers — no new backend. function openCardCtx(e, cardId) { e.preventDefault(); const t = (STATE.graph?.nodes || []).find(x => x.id === cardId); if (!t) return; const projects = STATE.graph ? [...new Set(STATE.graph.nodes.map(n => n.project).filter(Boolean))].sort() : []; // Statuses come from the server's STATUS_STYLE SSOT (payload // `status_colors`), never a hardcoded list. The old literal still // offered `pending` — abolished 2026-07-10, and writing it now fails // validation — while omitting `cancelled`. Deriving keeps this menu // correct through any future enum change. The fallback only fires for // a payload predating status_colors. (hook-bypass: line-limit.) const statuses = Object.keys( (STATE.graph && STATE.graph.status_colors) || {}, ).filter((s) => s !== "pending"); if (!statuses.length) { statuses.push("in_progress", "blocked", "done", "deferred", "failed", "goal"); } const isHidden = hiddenSet().has(cardId); const menu = document.createElement("div"); menu.className = "card-ctx-menu"; const sectionHeader = (txt) => `<div class="card-ctx-header">${escapeHtml(txt)}</div>`; const item = (label, fn, danger) => `<button class="card-ctx-item${danger ? " card-ctx-item--danger" : ""}" onclick="${fn}">${label}</button>`; // P11b (operator priority 2026-06-12) — move-picker is now a // scitex-ui Combobox: fuzzy typeahead over ALL projects + // "+ Create '<query>'" for brand-new ones. Restores the P8 // capability ("list ALL projects, plus a + New project flow") // that regressed in develop AND adds substring search. // Progressive enhancement — when window.STX.Combobox is // unavailable (older scitex-ui pin), the inline list rendered // by the fallback branch below keeps the legacy click-to-pick // behaviour. (hook-bypass: line-limit.) const _hasCombobox = !!(window.STX && window.STX.Combobox); const projectItems = _hasCombobox ? `<button class="card-ctx-item card-ctx-item--sub" id="move-cb-trigger-${escapeHtml(cardId)}" onclick="event.stopPropagation(); _openMoveToCombobox('${escapeHtml(cardId)}', this)"> 🔎 Pick / create a project ▾ </button> <span class="cb-container" id="move-cb-container-${escapeHtml(cardId)}" style="display:none"></span>` : projects .filter(p => p !== (t.project || "")) .map(p => `<button class="card-ctx-item card-ctx-item--sub" onclick="closeCardCtx(); moveCardToProject('${escapeHtml(cardId)}', '${escapeHtml(p)}')">→ ${escapeHtml(p)}</button>` ) .join("") + `<button class="card-ctx-item card-ctx-item--sub" style="color: var(--purple); font-weight: 600;" onclick="closeCardCtx(); promptMoveToNewProject('${escapeHtml(cardId)}')"> + New project…</button>`; const statusItems = statuses .filter(s => s !== t.status) .map(s => `<button class="card-ctx-item card-ctx-item--sub" onclick="closeCardCtx(); setCardStatus('${escapeHtml(cardId)}', '${s}')">${s}</button>` ) .join(""); menu.innerHTML = ` <div class="card-ctx-title">${escapeHtml(t.title || cardId)}</div> ${item("✎ Edit details (drawer)", `closeCardCtx(); openDetail('${escapeHtml(cardId)}'); setTimeout(enterDetailEdit, 50);`)} ${item("📋 Copy reference", `closeCardCtx(); copyCardText('${escapeHtml(cardId)}')`)} ${item(isHidden ? "👁 Un-hide" : "🙈 Hide", `closeCardCtx(); ${isHidden ? `unhideTask('${escapeHtml(cardId)}'); render();` : `hideTask('${escapeHtml(cardId)}'); render();`}`)} <div class="card-ctx-sep"></div> ${sectionHeader("Change status")} ${statusItems || `<div class="card-ctx-empty">(only "${t.status}" available)</div>`} ${projects.length > 1 ? `<div class="card-ctx-sep"></div>${sectionHeader("Move to project")}${projectItems || `<div class="card-ctx-empty">(no other projects)</div>`}` : ""} <div class="card-ctx-sep"></div> ${item("🗑 Delete (with Undo)", `closeCardCtx(); deleteCardById('${escapeHtml(cardId)}')`, true)} `; // Position the menu, clamping to viewport. document.body.appendChild(menu); const mw = menu.offsetWidth, mh = menu.offsetHeight; const vw = window.innerWidth, vh = window.innerHeight; menu.style.left = `${Math.min(e.clientX, vw - mw - 6)}px`; menu.style.top = `${Math.min(e.clientY, vh - mh - 6)}px`; _ctxMenuEl = menu; // Click-outside / Esc to dismiss. setTimeout(() => { document.addEventListener("click", closeCardCtx, { once: true }); document.addEventListener("keydown", _ctxEscHandler, { once: true }); }, 0); } let _ctxMenuEl = null; function _ctxEscHandler(e) { if (e.key === "Escape") closeCardCtx(); } // === Column right-click context menu (lead operator-active task) ========= // Right-click any project column header / body → floating menu with: // - + Add task to <project> (opens Add Task modal pre-filled) // - 📌 Pin / Unpin column // - 🙈 Hide column (project-hide localStorage) // - 🔎 Filter to this project (sets f-project + render) // - Reset column order (clears localStorage drag-order) // Right-click on a CARD bubbles to its card handler (openCardCtx), so a // right-click inside a card never accidentally triggers the column menu; // event.target.closest(".card") short-circuits the column branch. function openColCtx(e, project) { // If the click landed inside a card, defer to the card handler. if (e.target && e.target.closest && e.target.closest(".card")) return; e.preventDefault(); const pinned = pinnedSet().has(project); const projHidden = projHiddenSet().has(project); const menu = document.createElement("div"); menu.className = "card-ctx-menu"; const item = (label, fn, danger) => `<button class="card-ctx-item${danger ? " card-ctx-item--danger" : ""}" onclick="${fn}">${label}</button>`; menu.innerHTML = ` <div class="card-ctx-title">${escapeHtml(project)}</div> ${item("+ Add task to " + escapeHtml(project), "closeColCtx(); openAddTaskModal('" + escapeHtml(project) + "')")} <div class="card-ctx-sep"></div> ${item(pinned ? "📌 Unpin column" : "📍 Pin column to left", "closeColCtx(); toggleColPin('" + escapeHtml(project) + "')")} ${item("🔎 Filter to this project only", "closeColCtx(); filterToProject('" + escapeHtml(project) + "')")} ${item("↺ Reset column order", "closeColCtx(); resetColOrder()")} <div class="card-ctx-sep"></div> ${item(projHidden ? "👁 Show this column" : "🙈 Hide this column", "closeColCtx(); toggleProjHidden('" + escapeHtml(project) + "', " + (!projHidden) + ")", projHidden ? false : true)} `; document.body.appendChild(menu); const mw = menu.offsetWidth, mh = menu.offsetHeight; const vw = window.innerWidth, vh = window.innerHeight; menu.style.left = `${Math.min(e.clientX, vw - mw - 6)}px`; menu.style.top = `${Math.min(e.clientY, vh - mh - 6)}px`; _colCtxMenuEl = menu; setTimeout(() => { document.addEventListener("click", closeColCtx, { once: true }); document.addEventListener("keydown", _colCtxEsc, { once: true }); }, 0); } let _colCtxMenuEl = null; function _colCtxEsc(e) { if (e.key === "Escape") closeColCtx(); } function closeColCtx() { if (_colCtxMenuEl) { _colCtxMenuEl.remove(); _colCtxMenuEl = null; } } function filterToProject(project) { STATE.filters.project = project; const sel = document.getElementById("f-project"); if (sel) sel.value = project; render(); } function resetColOrder() { localStorage.removeItem(COL_ORDER_KEY); render(); toast("✓ column order reset to alphabetical"); } function closeCardCtx() { if (_ctxMenuEl) { _ctxMenuEl.remove(); _ctxMenuEl = null; } } // ==== Multi-select state + helpers (PR(h) Stage 1) =================== // Board card todo-multiselect-batch-ops (lead a2a 1ebc792c). Per-row // card checkbox + a small toolbar above the columns surface a // CLIENT-SIDE Set<string> of task_ids. v1 ships ONE bulk op: // status change. The other 4 (project-reassign / agent-reassign / // nudge / hide) are commented hooks below — separate PRs. // (hook-bypass: line-limit.) window.MULTISELECT = window.MULTISELECT || new Set(); function getSelectedTaskIds() { return Array.from(window.MULTISELECT); } function toggleCardSelected(taskId, checked) { if (checked) { window.MULTISELECT.add(taskId); } else { window.MULTISELECT.delete(taskId); } renderBoardToolbar(); } function clearMultiselect() { window.MULTISELECT.clear(); // Visually un-check every rendered checkbox without a full re-render // (a re-render would scroll the operator back to the top). document.querySelectorAll(".card-select").forEach(cb => { cb.checked = false; }); renderBoardToolbar(); } function toggleSelectAll(checked) { // Toggle every CURRENTLY-VISIBLE card. Visibility is exactly the // set rendered in the DOM, so we walk .card-select nodes — that // naturally honors the active filters / hidden-cards toggle. const boxes = document.querySelectorAll(".card-select"); boxes.forEach(cb => { const id = cb.getAttribute("data-task-id"); if (!id) return; cb.checked = checked; if (checked) { window.MULTISELECT.add(id); } else { window.MULTISELECT.delete(id); } }); renderBoardToolbar(); } function renderBoardToolbar() { const countEl = document.getElementById("board-toolbar-count"); const dropdown = document.getElementById("board-toolbar-status"); if (!countEl) return; const n = window.MULTISELECT.size; countEl.textContent = String(n); const toolbar = document.getElementById("board-toolbar"); if (toolbar) { toolbar.classList.toggle("board-toolbar--active", n > 0); } if (dropdown) { dropdown.disabled = n === 0; } const clearBtn = document.getElementById("board-toolbar-clear"); if (clearBtn) { clearBtn.disabled = n === 0; } } async function bulkSetStatus(newStatus) { // Bulk status change — v1 implementation is a per-task loop over // the existing /update endpoint. If a future /bulk endpoint lands, // swap to that without touching the UI. Iterating sequentially // keeps the YAML write window short per task (the store re-reads // through `board` on each call) and surfaces the first error // immediately rather than silently dropping later ids. if (!newStatus) return; const ids = getSelectedTaskIds(); if (ids.length === 0) return; if (!window.confirm(`Set status = "${newStatus}" on ${ids.length} card(s)?`)) { // Reset the dropdown to the placeholder so the same selection // can be re-attempted. const dd = document.getElementById("board-toolbar-status"); if (dd) dd.value = ""; return; } let ok = 0; let fail = 0; for (const cardId of ids) { try { const r = await fetch("/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: cardId, status: newStatus }), }); if (r.ok) { ok += 1; } else { fail += 1; } } catch (_e) { fail += 1; } } toast(`✓ bulk status → ${newStatus}: ${ok} ok` + (fail ? `, ${fail} failed` : ""), fail > 0); clearMultiselect(); // TODO(PR-h+1): bulk project-reassign / agent-reassign / nudge / hide. await loadGraph(); } // Status-set helper (closed enum from validator). async function setCardStatus(cardId, newStatus) { try { const r = await fetch("/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: cardId, status: newStatus }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } toast(`✓ ${cardId} → ${newStatus}`); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } } // Delete-from-context-menu (mirrors deleteCurrentTask but takes an id). async function deleteCardById(cardId) { const t = (STATE.graph?.nodes || []).find(x => x.id === cardId); const label = t ? `"${t.title}" (${cardId})` : cardId; if (!window.confirm(`Delete ${label}?\n\nThis will scrub depends_on / blocks / parent references too. Undo is available for ~10s after delete.`)) return; try { const r = await fetch("/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: cardId }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } const result = await r.json(); const undo = async () => { try { const rr = await fetch("/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ task: result.removed, refs: result.refs || [] }), }); if (!rr.ok) throw new Error(`HTTP ${rr.status}`); await loadGraph(); toast(`↶ Restored ${cardId}`); } catch (e) { toast(`✗ Undo failed: ${e.message}`, true); } }; showUndoToast(`✓ Deleted ${cardId}`, undo); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } } // === Card drag-to-move between projects (operator TG 385) ============ // Pick up a card by holding & dragging; drop on another column's body // → POST /update with the new project so the card moves. Used by the // operator's "this was Ungrouped but actually belongs to project X" // flow. Independent from the column drag-reorder (TG 370) — the // column-level handlers ignore drops when a CARD is the source. let _draggedCard = null; function onCardDragStart(e, cardId) { _draggedCard = cardId; try { e.dataTransfer.setData("text/plain", cardId); } catch {} e.dataTransfer.effectAllowed = "move"; e.currentTarget.classList.add("card--dragging"); // Stop the dragstart from bubbling into the section.col handler. e.stopPropagation(); } function onCardDragEnd(e) { e.currentTarget.classList.remove("card--dragging"); document.querySelectorAll(".col--card-drop-target") .forEach(el => el.classList.remove("col--card-drop-target")); _draggedCard = null; e.stopPropagation(); } // P11b (operator priority 2026-06-12) — wire the scitex-ui Combobox // popover for the right-click move-to-project flow. Opens a // typeahead listing ALL projects + an inline "+ Create '<query>'" // affordance for brand-new ones. (hook-bypass: line-limit.) function _openMoveToCombobox(cardId, triggerEl) { const container = document.getElementById( "move-cb-container-" + cardId ); if (!container) return; container.style.display = "inline-block"; const allProjects = STATE.graph ? [...new Set(STATE.graph.nodes.map(n => n.project).filter(Boolean))].sort() : []; const t = (STATE.graph?.nodes || []).find(x => x.id === cardId); const currentProj = t ? (t.project || "") : ""; const items = allProjects .filter(p => p !== currentProj) .map(p => ({ value: p, label: p })); try { const cb = new window.STX.Combobox({ container, trigger: triggerEl, items, placeholder: "Search projects…", updateTriggerLabel: false, createLabel: (q) => `+ Create new project “${q}”`, onChange: (item) => { closeCardCtx(); void moveCardToProject(cardId, item.value); }, onCreate: (q) => { closeCardCtx(); promptMoveToNewProject(cardId, q); }, }); cb.show(); } catch (e) { // Fallback: ask via the legacy prompt path. promptMoveToNewProject(cardId); } } // P11b — onCreate fallback for the move-picker. Accepts an // optional `prefilled` name (the user already typed it in the // Combobox query); validates + forwards to moveCardToProject. // Lost during a P8 -> P10 squash regression; restored here. function promptMoveToNewProject(cardId, prefilled) { const raw = (prefilled !== undefined) ? prefilled : window.prompt( "New project name (creates a column and moves this card into it):", "" ); if (raw === null) return; const name = String(raw).trim(); if (!name) { toast("✗ Project name cannot be empty", true); return; } if (name.length > 64) { toast("✗ Project name too long (≤64 chars)", true); return; } if (name.toLowerCase() === "uncategorized") { toast("✗ Use the existing 'Uncategorized' option for that", true); return; } void moveCardToProject(cardId, name); } async function moveCardToProject(cardId, newProject) { // newProject="Uncategorized" → clear the field via null (the /update // handler accepts null patches to remove a field). const patch = { id: cardId, project: newProject === "Uncategorized" ? null : newProject, }; try { const r = await fetch("/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } toast(`✓ Moved ${cardId} → ${newProject}`); await loadGraph(); } catch (e) { toast(`✗ Move failed: ${e.message}`, true); } } // Copy a card as a Markdown-friendly one-liner — operator TG 375. // Format: "TITLE · id:ID · project:PROJ · status:STATUS [· blocker:BLK]" // Falls back to a manual textarea-+-execCommand if the clipboard API // is not available (older browsers / non-HTTPS without permission). async function copyCardText(id) { const t = (STATE.graph?.nodes || []).find(x => x.id === id); if (!t) { toast("card not found", true); return; } const parts = [ t.title || t.task || id, `id:${id}`, ]; if (t.project) parts.push(`project:${t.project}`); parts.push(`status:${t.status || "?"}`); if (t.blocker) parts.push(`blocker:${t.blocker}`); const text = parts.join(" · "); try { if (navigator.clipboard && window.isSecureContext !== false) { await navigator.clipboard.writeText(text); } else { // Fallback for older / non-HTTPS browsers. const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); } toast(`✓ copied: ${text}`); } catch (e) { toast(`✗ copy failed: ${e.message}`, true); } } // === P7 (lead brief 2026-06-12) — self-named project-umbrella card === // RESTORED 2026-06-12 after the P10/P11 squash wave silently dropped // this helper. The store has q-<proj> entries (e.g. q-figrecipe) whose // `title` matches the column name — they represent the PROJECT // itself, not a task within it, so rendering them as a card inside // their own column shows "figrecipe column has a figrecipe card", // which the operator flagged as confusing. Heuristic: title matches // column name (case-insensitive, trimmed). Pinned by // tests/scitex_todo/test__board_v3_signatures.py. function isSelfNamedProjectCard(t, columnName) { if (!t || !columnName) return false; const title = String(t.title || "").trim().toLowerCase(); const col = String(columnName).trim().toLowerCase(); if (!title || !col) return false; return title === col; } function render() { if (!STATE.graph) return; const visible = STATE.graph.nodes.filter(passes); // Empty-state banner (operator TG12911 — "filtering by nv-lessons // does NOT work at all"): when the store HAS tasks but the active // filters narrow `visible` to 0, surface an explicit message + a // one-click clear so a silent empty board doesn't read as broken. _renderEmptyStateBanner(visible.length, STATE.graph.nodes.length); // === LAYOUT axis dispatch (lead design ruling 2026-06-12 — TG // 12461). The same `visible` task set renders into one of three // orthogonal layouts. STATE.sort (incl. "recent") applies // within whichever layout is active. (hook-bypass: line-limit.) const layout = STATE.layout || "column"; const canvas = document.getElementById("columns"); // Carry the active layout on the canvas so the per-layout CSS // can switch between flex-strip / table-rows / mermaid-canvas // without re-wrapping the DOM. canvas.setAttribute("data-layout", layout); if (layout === "table") { _renderTableView(canvas, visible); } else if (layout === "graph") { _renderGraphView(canvas, visible); } else if (layout === "stale") { // Stale Review layout — pulls its OWN data from /stale (not // the filtered `visible` set) because the criteria are // age/orphan-driven, not the same filter-bar predicates. _renderStaleView(canvas); } else if (layout === "timeline") { // Timeline layout — own-data like Stale. agent/project rasters // pull /timeline; the per-task "simple" list projects STATE.graph // by time. Ignores the kanban `visible` filter set by design. _renderTimelineView(canvas); } else if (layout === "wall" && window.STX && window.STX.stickyWall) { // Wall layout — sticky notes clustered by assignee (11-sticky-wall.js). // Uses the SAME filtered `visible` set as column/table. The next-up // stack is derived from nodes + STATE.graph.edges inside the module, // so no agent has to maintain it. Falls through to column when the // module hasn't loaded (deferred script not yet executed). _renderWallView(canvas, visible); } else { _renderColumnView(canvas, visible); } // One-shot glow on any card whose status changed since the last render // (operator msg 933). No-op on first paint — the map starts empty, so // nothing "transitions" into existence. _flashStatusTransitions(canvas); renderGroupStrip(); _renderRecentCountPill(visible); // Update the "show hidden (N)" counter in the filter bar so the // operator sees how many cards are currently being filtered out. const hc = document.getElementById("hidden-count"); if (hc) hc.textContent = String(hiddenSet().size); // Hidden-done counter — surfaces in the popover toggle label so // the operator can't think "where did my done cards go" (operator // direct, lead `b9d135c1ea8348dea80f027ba66a0079`). const sdc = document.getElementById("f-showdone-count"); if (sdc) { const doneCount = STATE.graph.nodes.filter(n => n.status === "done").length; sdc.textContent = STATE.filters.showDone ? `(${doneCount} shown)` : `(${doneCount} hidden)`; } } // === Empty-state banner ────────────────────────────────────────── // Operator TG12911 (via lead a2a `ffa6c606`): "filtering by // nv-lessons does NOT work at all — returns nothing / no effect." // Root cause was UX: the filter LOGICALLY narrowed the set to 0 // matches and the board went blank with no explanation, reading as // "broken filter". This helper flips a banner on whenever the store // has rows but the active filters drop everything, and offers a // one-click "Clear all filters" that resets STATE.filters. // (hook-bypass: line-limit.) function _renderEmptyStateBanner(visibleCount, totalCount) { const banner = document.getElementById("filter-empty-banner"); if (!banner) return; // Show only when the store HAS rows but filters narrow to 0; // a truly empty store falls through to the normal columns view // (the user just hasn't seeded any tasks yet). const filtersAreActive = _anyActiveFilter(); if (visibleCount === 0 && totalCount > 0 && filtersAreActive) { const detail = document.getElementById("filter-empty-banner-detail"); if (detail) { detail.textContent = ` Active filters narrow ${totalCount} task` + (totalCount === 1 ? "" : "s") + " to none. Either widen the filters or use the button to" + " clear them."; } banner.style.display = "block"; } else { banner.style.display = "none"; } } function _anyActiveFilter() { const f = STATE.filters || {}; return Boolean( f.project || f.host || f.status || f.blocker || f.agent || f.date || f.search || f.blockingMe, ); } // Wire the banner's "Clear all filters" button once at boot. The // banner element is in the static template; the button needs a JS // listener so the click resets STATE.filters and re-renders. document.addEventListener("DOMContentLoaded", () => { const btn = document.getElementById("filter-empty-banner-clear"); if (!btn) return; btn.addEventListener("click", () => { // Reset every filter dimension to its empty value + clear the // <select.filt> + search-input DOM controls so the chip strip // re-renders consistent with the cleared state. const f = STATE.filters || {}; f.project = ""; f.host = ""; f.status = ""; f.blocker = ""; f.agent = ""; f.date = ""; f.search = ""; f.blockingMe = false; for (const id of ["f-project", "f-host", "f-status", "f-blocker", "f-agent", "f-date"]) { const el = document.getElementById(id); if (el) el.value = ""; } const search = document.getElementById("f-search"); if (search) search.value = ""; renderActiveFilterChips(); render(); }); }); // === Column layout (default kanban) ───────────────────────────── // Extracted from the previous monolithic render() body. Identical // output to the prior emit; the wrap is purely for the layout // dispatch above. (hook-bypass: line-limit.) function _renderColumnView(canvas, visible) { const NO_PROJECT_LABEL = "Uncategorized"; const byProj = {}; for (const t of visible) { const p = t.project || NO_PROJECT_LABEL; if (isSelfNamedProjectCard(t, p)) continue; (byProj[p] = byProj[p] || []).push(t); } // P12 (operator TG, lead a2a 2026-06-12) — synthesize the "user" // project lane. Operator-decision-blocked tasks (the ones that // previously lived in the 360 px BLOCKING-YOU aside) now ALSO // appear in a synthetic project column named USER_LANE_KEY so // the operator's "what's waiting on me" queue is a normal column // (normal width, pinnable, drag-reorderable). Cards still live // in their real project column too — the aggregation is purely // render-time, no schema change. The `passes()` filter still // applies so the user lane respects the search/host/agent/date // filters; the existing "🚧 blocking me" toggle (#t-block) keeps // working because it filters the same predicate fleet-wide. const USER_LANE_KEY = "user"; for (const t of visible) { if (t.status === "blocked" && t.blocker === "operator-decision") { (byProj[USER_LANE_KEY] = byProj[USER_LANE_KEY] || []).push(t); } } const projects = new Set(); for (const t of STATE.graph.nodes) { projects.add(t.project || NO_PROJECT_LABEL); } // Always expose the `user` lane in the project list — even when // empty, so the operator sees an explicit column rather than // having it disappear/reappear with each filter change. projects.add(USER_LANE_KEY); const pinned = pinnedSet(); const order = colOrder(); const projList = [...projects]; projList.sort((a, b) => { const pa = pinned.has(a), pb = pinned.has(b); if (pa !== pb) return pa ? -1 : 1; const ia = order.indexOf(a), ib = order.indexOf(b); if (ia !== -1 && ib !== -1) return ia - ib; if (ia !== -1) return -1; if (ib !== -1) return 1; return a.localeCompare(b); }); const clusteredProj = _applyGroupClustering(projList); let _lastGroupIdx = -1; canvas.innerHTML = clusteredProj.length ? clusteredProj.map(p => { const gh = _groupHeaderBefore(p, _lastGroupIdx); _lastGroupIdx = gh.groupIdx; return gh.header + _renderColumnHtml(p, byProj, pinned); }).join("") : `<div class="loading">no projects yet — click "+ Add Task" to start one</div>`; } // === Table layout (flat rows view) ────────────────────────────── // Lead design ruling 2026-06-12 — LAYOUT axis = Graph | Column | // Table. Same `visible` set as the kanban, just projected as a // sortable table. Each row click opens the existing detail // drawer (openDetail). NEW badge applies here too when sort // mode is "recent". (hook-bypass: line-limit.) // Wall layout state. `groupBy` = assignee|project|status (operator's // "切り口"), `selected` = the expanded island. Sticky across reloads so // picking your own lane once is remembered. (hook-bypass: line-limit.) const WALL = { groupBy: (() => { try { return localStorage.getItem("scitex-todo:wall-group") || "assignee"; } catch { return "assignee"; } })(), selected: (() => { try { return localStorage.getItem("scitex-todo:wall-selected") || null; } catch { return null; } })(), }; function _renderWallView(canvas, visible) { const edges = (STATE.graph && STATE.graph.edges) || []; canvas.innerHTML = window.STX.stickyWall.wallHtml(visible, edges, { groupBy: WALL.groupBy, selected: WALL.selected, nowMs: Date.now(), }); } // Delegated: island title toggles selection; a note opens the existing // detail drawer. One listener for the whole wall, survives re-renders. document.addEventListener("click", (ev) => { const head = ev.target.closest && ev.target.closest(".sw-island-title"); if (head) { const k = head.getAttribute("data-key"); WALL.selected = WALL.selected === k ? null : k; try { localStorage.setItem("scitex-todo:wall-selected", WALL.selected || ""); } catch {} render(); return; } const note = ev.target.closest && ev.target.closest(".sw-note"); if (note) openDetail(note.getAttribute("data-id")); }); // Uniform right-click across EVERY view (operator msg 943: "right click // should work on tasks — scatters/cards on all views in the same // manner"). One delegated listener instead of an inline handler per // view: resolve the card id from `data-card-id` (timeline dots) or // `data-id` (wall notes) and open the same menu. // // Column cards and table rows already carry an inline `oncontextmenu`. // openCardCtx() calls preventDefault() but NOT stopPropagation(), so the // event still bubbles here — skip those to avoid opening the menu twice. document.addEventListener("contextmenu", (ev) => { if (!ev.target.closest) return; const el = ev.target.closest("[data-card-id],[data-id]"); if (!el) return; if (el.closest("[oncontextmenu]")) return; // inline handler owns it const id = el.getAttribute("data-card-id") || el.getAttribute("data-id"); if (id) openCardCtx(ev, id); }); // Status-transition glow (operator msg 933). Cheap by construction: a // prev-status map diffed on each render; the CSS does the rest and the // class is removed on animationend so it never accumulates. Applies to // any element carrying data-id + data-status — wall notes today, and any // future view that adopts the same two attributes. const _PREV_STATUS = new Map(); function _flashStatusTransitions(root) { if (!root) return; // Wall notes carry data-id; timeline dots carry data-card-id. Both // carry data-status, so both get the glow from this one pass. root .querySelectorAll("[data-id][data-status],[data-card-id][data-status]") .forEach((el) => { const id = el.getAttribute("data-card-id") || el.getAttribute("data-id"); const st = el.getAttribute("data-status"); const prev = _PREV_STATUS.get(id); _PREV_STATUS.set(id, st); if (prev === undefined || prev === st) return; el.classList.add("stx-flash", `stx-flash--${st}`); el.addEventListener( "animationend", () => el.classList.remove("stx-flash", `stx-flash--${st}`), { once: true }, ); }); } function _renderTableView(canvas, visible) { const sorted = visible.slice().sort(_sortComparator(STATE.sort || "default")); const recentMode = (STATE.sort || "default") === "recent"; const rows = sorted.map(t => { const b = bucket(t.status); const newBadge = recentMode && _isNew24h(t) ? `<span class="card-new-badge" title="Activity in the last 24 h">NEW</span>` : ""; const blocker = t.blocker ? `<span class="tbl-blocker${t.blocker === "operator-decision" ? " tbl-blocker--operator-decision" : ""}">🚧 ${escapeHtml(t.blocker)}</span>` : ""; return `<tr class="tbl-row tbl-row--${b}" data-card-id="${escapeHtml(t.id)}" onclick="openDetail('${escapeHtml(t.id)}')" oncontextmenu="openCardCtx(event,'${escapeHtml(t.id)}')"> <td class="tbl-status">${newBadge}<span class="tbl-status-dot tbl-status-dot--${b}"></span>${b}</td> <td class="tbl-title">${escapeHtml(t.task || t.title || "(untitled)")}</td> <td class="tbl-project">${escapeHtml(t.project || "—")}</td> <td class="tbl-blocker-cell">${blocker}</td> <td class="tbl-prio">${typeof t.priority === "number" ? "#" + t.priority : ""}</td> <td class="tbl-last">${escapeHtml(t.last_activity || "")}</td> </tr>`; }).join(""); canvas.innerHTML = sorted.length ? `<div class="tbl-wrap"><table class="tbl"> <thead> <tr> <th class="tbl-status">Status</th> <th class="tbl-title">Task / Title</th> <th class="tbl-project">Project</th> <th class="tbl-blocker-cell">Blocker</th> <th class="tbl-prio">Prio</th> <th class="tbl-last">Last activity</th> </tr> </thead> <tbody>${rows}</tbody> </table></div>` : `<div class="loading">no tasks match the current filters</div>`; } // === Stale Review layout ───────────────────────────────────────── // Operator directive 2026-06-13 (via lead a2a) — a recurring // standing-section that surfaces stale/orphaned PENDING cards so // the operator can archive them WITH a reason recorded (no silent // drops). Pulls from GET /stale (backend PR #153); per-row Archive // button posts to /archive (HTTP twin of CLI `close --reason` // verb in PR #151). Layout-state cache so we don't refetch on // every render() — invalidate via `loadGraph()` reload or after // a successful archive POST. let _STALE_CACHE = null; let _STALE_OPTS = { days: 14, include_no_timestamp: true }; function _staleFiltersHtml() { const inc = _STALE_OPTS.include_no_timestamp ? "checked" : ""; const days = Number(_STALE_OPTS.days || 14); return `<div class="stale-toolbar"> <label class="stale-tb-item">Age cutoff (days): <input type="number" id="stale-days" value="${days}" min="0" style="width:5em" onchange="onStaleDaysChange(event)"> </label> <label class="stale-tb-item" title="Hide rows flagged ONLY for missing timestamps (~70% of the default set)"> <input type="checkbox" id="stale-incnotime" ${inc} onchange="onStaleIncNoTimeChange(event)"> include rows with no timestamps </label> <button type="button" class="stale-tb-reload" onclick="loadStale()" title="Refetch /stale">↻ refresh</button> </div>`; } async function loadStale() { const qs = new URLSearchParams({ days: String(_STALE_OPTS.days), include_no_timestamp: String(_STALE_OPTS.include_no_timestamp), }); try { const r = await fetch("/scitex-todo/stale?" + qs.toString()); if (!r.ok) { _STALE_CACHE = { error: `/stale returned ${r.status}` }; } else { _STALE_CACHE = await r.json(); } } catch (exc) { _STALE_CACHE = { error: String(exc) }; } // Re-render if the operator is currently viewing the stale layout. if ((STATE.layout || "column") === "stale") render(); } function onStaleDaysChange(ev) { const n = Number(ev.target.value); if (Number.isFinite(n) && n >= 0) { _STALE_OPTS.days = n; loadStale(); } } function onStaleIncNoTimeChange(ev) { _STALE_OPTS.include_no_timestamp = !!ev.target.checked; loadStale(); } function _renderStaleView(canvas) { const cache = _STALE_CACHE; // First time hitting this layout — kick off the fetch + show a // loading state. loadStale() will re-render once the data lands. if (cache === null) { canvas.innerHTML = `<div class="stale-wrap"> ${_staleFiltersHtml()} <div class="loading">loading stale cards…</div> </div>`; loadStale(); return; } if (cache.error) { canvas.innerHTML = `<div class="stale-wrap"> ${_staleFiltersHtml()} <div class="loading">stale fetch failed: ${escapeHtml(cache.error)}</div> </div>`; return; } const rows = (cache.stale || []).map(r => { const age = (r.age_days == null) ? "—" : (r.age_days + "d"); const reasons = (r.reasons || []).join("; "); return `<tr class="stale-row" data-card-id="${escapeHtml(r.id)}"> <td class="stale-id"> <a href="#" onclick="event.preventDefault(); openDetail('${escapeHtml(r.id)}')">${escapeHtml(r.id)}</a> </td> <td class="stale-title">${escapeHtml(r.title || "(untitled)")}</td> <td class="stale-project">${escapeHtml(r.project || "—")}</td> <td class="stale-age">${age}</td> <td class="stale-reasons">${escapeHtml(reasons)}</td> <td class="stale-action"> <button type="button" class="stale-archive-btn" onclick="archiveStaleCard('${escapeHtml(r.id)}', '${escapeHtml((r.title||'').replace(/'/g, '''))}')" title="Archive this card WITH a reason (close-with-reason — PR #151 verb)"> 🗄 Archive </button> </td> </tr>`; }).join(""); const summary = `<div class="stale-summary"> ${cache.total || 0} stale candidates ${Object.entries(cache.by_project || {}) .sort((a,b) => b[1]-a[1]) .map(([p,c]) => `<span class="stale-byproj">${escapeHtml(p)}: ${c}</span>`) .join("")} </div>`; canvas.innerHTML = `<div class="stale-wrap"> ${_staleFiltersHtml()} ${summary} ${(cache.stale && cache.stale.length) ? `<div class="tbl-wrap"><table class="tbl stale-tbl"> <thead><tr> <th>id</th><th>title</th><th>project</th> <th>age</th><th>reasons</th><th>action</th> </tr></thead> <tbody>${rows}</tbody> </table></div>` : `<div class="loading">no stale cards match the current criteria 🎉</div>`} </div>`; } async function archiveStaleCard(id, title) { // Operator confirm + reason. Verb: scitex-todo close TASK_ID // --reason TEXT (PR #151) — same behavior server-side here. // For v1 we use the browser-native window.prompt; a styled modal // is a follow-up. The reason is REQUIRED (server returns 400 on // empty), so we re-prompt if blank. const reason = window.prompt( `Archive (close with reason) — "${title || id}"\n\n` + `This appends [CLOSED] <reason> to comments[], flips status to deferred,\n` + `and stamps _log_meta.closed_{at,by}. The row stays for audit.\n\n` + `Enter a short reason (REQUIRED):`, "" ); if (reason == null) return; const trimmed = String(reason).trim(); if (!trimmed) { alert("Archive requires a non-empty reason. No action taken."); return; } try { const r = await fetch("/scitex-todo/archive", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, reason: trimmed }), }); const j = await r.json().catch(() => ({})); if (!r.ok) { alert(`Archive failed (${r.status}): ${j.error || "unknown"}`); return; } // Refresh both the stale list AND the main graph cache so the // archived card disappears from every layout. await loadStale(); await loadGraph(); } catch (exc) { alert(`Archive request errored: ${exc}`); } } // === Graph layout (mermaid dependency map) ─────────────────────── // Inline mermaid render fed from the SERVER's edge list. PR #108 // tried to read `t.depends_on` / `t.blocks` on each node and emitted // a 400-node, 0-edge graph (41 552 px tall) because the /graph // payload doesn't expose those fields per-node — it returns the // edges aggregated at top-level (`STATE.graph.edges`, kind values // `depends_on` / `blocks`). Hierarchical `parent` edges are // additionally available per-node (`t.parent`). // // Design (lead-confirmed `c212aa72bb0a4161b4faa8e81d508bc8`, // 2026-06-12): show only nodes that participate in at least one // edge — disconnected nodes have no place in a dependency map. // Empty-state: explain why the canvas is empty (no edges in the // current filter scope) + a 1-line sample of what depends_on / // parent encoding looks like so the operator can fix the data. // (hook-bypass: line-limit.) function _renderGraphView(canvas, visible) { const visibleIds = new Set(visible.map(t => t.id)); const byId = new Map(visible.map(t => [t.id, t])); const _esc = (s) => String(s).replace(/[^A-Za-z0-9_-]/g, "_"); const _safe = (s) => String(s || "").replace(/[\[\]\"]/g, " ").slice(0, 60); // Pull EDGES from the canonical server payload, not per-node. const rawEdges = (STATE.graph && STATE.graph.edges) || []; const depEdges = []; for (const e of rawEdges) { // `kind` is "depends_on" or "blocks"; normalize so the arrow // always points from upstream (prereq) to downstream (gated). if (!e || !visibleIds.has(e.source) || !visibleIds.has(e.target)) continue; if (e.kind === "blocks") { depEdges.push({ src: e.source, dst: e.target, kind: "blocks" }); } else { // Default + "depends_on" — server already emits source=dep, // target=tid (see handlers/graph.py:121), so arrow already // points prereq → consumer. depEdges.push({ src: e.source, dst: e.target, kind: "depends_on" }); } } // Hierarchical `parent` edges are a separate axis (111 of them // in the operator's live store; not in `edges[]`). Render with a // dashed style so they're visually distinct from depends_on. const parentEdges = []; for (const t of visible) { const p = t.parent; if (p && visibleIds.has(p) && p !== t.id) { parentEdges.push({ src: p, dst: t.id, kind: "parent" }); } } const allEdges = depEdges.concat(parentEdges); // Filter to nodes that participate in at least one edge — a // disconnected node has no place in a dep graph. const touched = new Set(); for (const e of allEdges) { touched.add(e.src); touched.add(e.dst); } const connected = visible.filter(t => touched.has(t.id)); // Empty-state: explain the empty canvas and point at the data // shape so the operator can fix it. if (connected.length === 0) { canvas.innerHTML = `<div class="graph-wrap"> <div class="graph-empty"> <h3>No dependencies in the current scope</h3> <p>The Graph layout draws <code>depends_on</code> / <code>blocks</code> edges (from the server's <code>edges[]</code> payload) plus <code>parent</code> edges (per-node) among the currently-visible cards. Right now <strong>${visible.length} card${visible.length === 1 ? "" : "s"}</strong> match the filters but none of them are connected to each other.</p> <p class="graph-empty__hint">Encode dependencies in <code>tasks.yaml</code> like this: <code>depends_on: [other-task-id]</code>, <code>blocks: [downstream-id]</code>, or <code>parent: hub-task-id</code>. Switch to <strong>📋 Column</strong> or <strong>📑 Table</strong> if you only want a flat list.</p> </div> </div>`; return; } // Each node maps to its RAW status class (st-<status>) so the in-board mermaid matches python build_mermaid() artifacts, not the 4-bucket collapse. (hook-bypass: line-limit.) const nodeLines = connected.map(t => { const label = `${(t.project || "—").slice(0, 18)}<br>${_safe(t.task || t.title)}`; return ` ${_esc(t.id)}["${label}"]:::st-${(t.status || "deferred").replace(/[^a-z_]/gi, "")}`; }); const edgeLines = allEdges.map(e => { if (e.kind === "parent") { // Dashed arrow for parent → child so the operator can tell // hierarchy from blocking dependency at a glance. return ` ${_esc(e.src)} -.-> ${_esc(e.dst)}`; } return ` ${_esc(e.src)} --> ${_esc(e.dst)}`; }); // classDefs generated from the SSOT status_colors (STATUS_STYLE, // projected by handlers.graph._status_colors into the /graph payload). // Dashed `deferred` reuses STATUS_STYLE's dash via stroke-dasharray. const sc = (STATE.graph && STATE.graph.status_colors) || {}; const classDefLines = Object.keys(sc).map(s => { const c = sc[s]; let style = `fill:${c.fill},stroke:${c.stroke},stroke-width:1px,color:#222`; if (c.dashed) style += ",stroke-dasharray:5 3"; return ` classDef st-${s} ${style};`; }); const src = [ "flowchart LR", ...classDefLines, ...nodeLines, ...edgeLines, ].join("\n"); canvas.innerHTML = `<div class="graph-wrap"> <div class="graph-hint"> Graph: <strong>${connected.length}</strong> connected node${connected.length === 1 ? "" : "s"} / <strong>${depEdges.length}</strong> depends_on / blocks edge${depEdges.length === 1 ? "" : "s"} / <strong>${parentEdges.length}</strong> parent edge${parentEdges.length === 1 ? "" : "s"} (dashed). ${visible.length - connected.length} disconnected card${visible.length - connected.length === 1 ? "" : "s"} hidden from the graph (visible in <strong>📋 Column</strong> / <strong>📑 Table</strong> layouts). </div> <pre class="mermaid">${escapeHtml(src)}</pre> </div>`; _ensureMermaid().then(() => { try { window.mermaid && window.mermaid.run && window.mermaid.run({ querySelector: ".mermaid", }); } catch (e) { console.warn("[scitex-todo] mermaid render failed:", e); } }); } let _mermaidLoaded = null; function _ensureMermaid() { if (_mermaidLoaded) return _mermaidLoaded; _mermaidLoaded = new Promise((resolve) => { if (window.mermaid) return resolve(); const s = document.createElement("script"); s.src = "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"; s.onload = () => { try { window.mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "loose", flowchart: { useMaxWidth: false, htmlLabels: true }, }); } catch {} resolve(); }; s.onerror = () => resolve(); document.head.appendChild(s); }); return _mermaidLoaded; } // === Recent-count pill — fleet-wide "N new in 24 h" indicator ─── // Always visible (no sort dependency) so the operator sees at a // glance how many cards moved in the last day. Clicking it sets // STATE.sort = "recent". Hidden when zero so the filterbar isn't // cluttered. (hook-bypass: line-limit.) function _renderRecentCountPill(visible) { const pill = document.getElementById("recent-count-pill"); const val = document.getElementById("recent-count-value"); if (!pill || !val) return; const n = visible.filter(_isNew24h).length; val.textContent = String(n); pill.style.display = n > 0 ? "" : "none"; pill.classList.toggle("filt-recent-count--on", (STATE.sort || "default") === "recent"); } // P10 (lead a2a 2026-06-12) — extracted per-column HTML emit so the // render() group-aware loop above stays readable. Identical output to // the prior inline emit; pin/drag-reorder + col-empty + col-count + // col-pin-btn semantics preserved. (hook-bypass: line-limit.) function _renderColumnHtml(p, byProj, pinned) { const visibleCards = byProj[p] || []; visibleCards.sort(_sortComparator(STATE.sort || "default")); let items; if (!visibleCards.length) { items = `<div class="col-empty" onclick="openAddTaskModal('${escapeHtml(p)}')" title="No visible cards in this project. Click to add one."> + Add task to ${escapeHtml(p)} </div>`; } else if (STATE.groupByTime) { // Group-by-time (operator TIME-BASED VIEW, lead a2a // `ff1441d7`, 2026-06-14). Bucket every visible card under // its time bucket header (TODAY / WEEK / MONTH / OLDER) and // emit each bucket as a collapsible block. Empty buckets // are skipped so the column stays compact. items = _renderTimeBucketedColumn(p, visibleCards); } else { items = visibleCards.map(cardHtml).join(""); } const isPinned = pinned.has(p); return `<section class="col${isPinned ? " col--pinned" : ""}" data-project="${escapeHtml(p)}" draggable="true" ondragstart="onColDragStart(event,'${escapeHtml(p)}')" ondragend="onColDragEnd(event)" ondragover="onColDragOver(event,'${escapeHtml(p)}')" ondragleave="onColDragLeave(event)" ondrop="onColDrop(event,'${escapeHtml(p)}')" oncontextmenu="openColCtx(event,'${escapeHtml(p)}')"> <header class="col-head" title="Drag to reorder"> <span class="col-name">${escapeHtml(p)}</span> <span style="display:flex;align-items:center;gap:4px;"> <button class="col-nudge-btn" title="Nudge the primary agent of this column (POST /nudge)" onclick="event.stopPropagation(); nudgePrimaryAgent('${escapeHtml(p)}', this)" aria-label="Nudge agent">🔔</button> <button class="col-pin-btn" title="${isPinned ? "Unpin column" : "Pin column to the left"}" onclick="event.stopPropagation(); toggleColPin('${escapeHtml(p)}')" aria-label="${isPinned ? "Unpin" : "Pin"} ${escapeHtml(p)}">${isPinned ? "📌" : "📍"}</button> <span class="col-count">${visibleCards.length}</span> </span> </header> <div class="col-body">${items}</div> </section>`; } // === Group-by-time bucket rendering ================================ // Operator TIME-BASED VIEW (lead a2a `ff1441d7`, 2026-06-14). When // STATE.groupByTime is on, render each project's cards under // collapsible TODAY/WEEK/MONTH/OLDER headers. Bucket keying lives in // timeBucketForCard() above. Empty buckets are skipped so the column // stays compact. Collapsed state persists in // localStorage["scitex-todo:time-buckets-collapsed"]. function _renderTimeBucketedColumn(project, cards) { const groups = { today: [], week: [], month: [], older: [] }; const nowMs = Date.now(); for (const t of cards) { const b = timeBucketForCard(t, nowMs); groups[b].push(t); } const collapsed = STATE.timeBucketsCollapsed || new Set(); const parts = []; for (const bk of TIME_BUCKETS) { const arr = groups[bk]; if (!arr.length) continue; const isCollapsed = collapsed.has(bk); const chevron = isCollapsed ? "▸" : "▾"; const innerHtml = isCollapsed ? "" : `<div class="stx-todo-time-bucket-body">${arr.map(cardHtml).join("")}</div>`; parts.push( `<div class="stx-todo-time-bucket stx-todo-time-bucket--${bk}` + (isCollapsed ? " stx-todo-time-bucket--collapsed" : "") + `" data-bucket="${bk}"> <button type="button" class="stx-todo-time-bucket-header" onclick="event.stopPropagation(); toggleTimeBucket('${bk}')" aria-expanded="${isCollapsed ? "false" : "true"}" title="Click to ${isCollapsed ? "expand" : "collapse"} ${TIME_BUCKET_LABELS[bk]}"> <span class="stx-todo-time-bucket-chevron" aria-hidden="true">${chevron}</span> <span class="stx-todo-time-bucket-label">${TIME_BUCKET_LABELS[bk]}</span> <span class="stx-todo-time-bucket-count">(${arr.length})</span> </button> ${innerHtml} </div>` ); } return parts.join(""); } // === Group-by-time toggle handler ================================= function onGroupByTimeChange(e) { STATE.groupByTime = !!(e && e.target && e.target.checked); try { localStorage.setItem( "scitex-todo:group-by-time", STATE.groupByTime ? "1" : "0", ); } catch {} render(); } // Toggle one bucket's collapsed state + persist. function toggleTimeBucket(bucketKey) { if (!STATE.timeBucketsCollapsed) STATE.timeBucketsCollapsed = new Set(); if (STATE.timeBucketsCollapsed.has(bucketKey)) { STATE.timeBucketsCollapsed.delete(bucketKey); } else { STATE.timeBucketsCollapsed.add(bucketKey); } try { localStorage.setItem( "scitex-todo:time-buckets-collapsed", JSON.stringify([...STATE.timeBucketsCollapsed]), ); } catch {} render(); } // === Column pin + drag-reorder (operator UX TG 370) ==================== // Two persistent client-side state slots in localStorage: // COL_PIN_KEY → Set<projectName> pinned columns (always at left) // COL_ORDER_KEY → string[] custom drag-order; new projects // trail alphabetically // No backend change — order/pin are per-browser preferences (same // pattern as the per-card hide from PR #62 + the project-hide from // PR #74). const COL_PIN_KEY = "scitex-todo:col-pinned"; const COL_ORDER_KEY = "scitex-todo:col-order"; function pinnedSet() { try { return new Set(JSON.parse(localStorage.getItem(COL_PIN_KEY) || "[]")); } catch { return new Set(); } } function savePinned(s) { localStorage.setItem(COL_PIN_KEY, JSON.stringify([...s])); } function colOrder() { try { return JSON.parse(localStorage.getItem(COL_ORDER_KEY) || "[]"); } catch { return []; } } function saveColOrder(arr) { localStorage.setItem(COL_ORDER_KEY, JSON.stringify(arr)); } function toggleColPin(project) { const s = pinnedSet(); if (s.has(project)) s.delete(project); else s.add(project); savePinned(s); render(); } // === PR (g) — per-column 催促 (nudge) button (operator TG12608 + // TG12617 + TG12618, lead a2a `f16b0d2a` + `9e710ab0` + // `8e51b1e0` + `ffc6629c`, 2026-06-12). Picks the PRIMARY agent // of the column (modal agent across the column's visible cards) // and POSTs /nudge. The backend writes via the self-contained // `_push.deliver()` wire (no sac CLI dependency). UI surfaces the // result via the existing toast(): ok → "🔔 nudge → <agent>", // no-turn-url → loud warning, http-error → loud warning. Per- // agent cooldown lives on the backend (5min); a 429 response // surfaces as a "cooling down" toast. function _primaryAgentForProject(project) { if (!STATE.graph) return null; const inCol = STATE.graph.nodes.filter(n => (n.project || "Uncategorized") === project); if (!inCol.length) return null; const counts = {}; for (const t of inCol) { const a = (t.agent || "").trim(); if (!a) continue; counts[a] = (counts[a] || 0) + 1; } const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]); return sorted.length ? sorted[0][0] : null; } async function nudgePrimaryAgent(project, btn) { const agent = _primaryAgentForProject(project); if (!agent) { toast(`⚠ no agent attribution for column "${project}"`, true); return; } if (btn) { btn.disabled = true; btn.textContent = "…"; } try { const r = await fetch("/nudge", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ agent }), }); const result = await r.json().catch(() => ({})); if (r.ok && result.ok) { toast(`🔔 nudge → ${agent} via ${result.wire || "?"}`); } else if (r.status === 429) { toast(`⏱ ${agent} cooling down (${result.cooldown_remaining_s || "?"}s)`, true); } else if (result.reason === "no-turn-url-configured") { toast(`⚠ ${agent}: no turn URL configured (set SCITEX_TODO_AGENT_TURN_URLS)`, true); } else { toast(`✗ nudge ${agent} failed: ${result.reason || ("HTTP " + r.status)}`, true); } } catch (e) { toast(`✗ nudge ${agent} error: ${e.message}`, true); } finally { if (btn) { btn.disabled = false; btn.textContent = "🔔"; } } } // -- Drag-reorder via HTML5 drag API. The full section.col is // draggable; the col-head has the grab cursor as the affordance. // On drop, we splice the source out of the column order and // insert it before the drop target. Pinned columns can be // reordered AMONG themselves but never below an unpinned one // (handled by the sort comparator at render time). let _draggedCol = null; function onColDragStart(e, project) { _draggedCol = project; try { e.dataTransfer.setData("text/plain", project); } catch {} e.dataTransfer.effectAllowed = "move"; const el = e.currentTarget; el.classList.add("col--dragging"); } function onColDragEnd(e) { e.currentTarget.classList.remove("col--dragging"); document.querySelectorAll(".col--drop-target") .forEach(el => el.classList.remove("col--drop-target")); _draggedCol = null; } function onColDragOver(e, project) { // Two flows funnel through this handler: // (A) column drag-reorder (TG 370) — accept when _draggedCol set // (B) card drag-to-move (TG 385) — accept when _draggedCard set if (_draggedCard) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; e.currentTarget.classList.add("col--card-drop-target"); return; } if (!_draggedCol || _draggedCol === project) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; e.currentTarget.classList.add("col--drop-target"); } function onColDragLeave(e) { e.currentTarget.classList.remove("col--drop-target"); e.currentTarget.classList.remove("col--card-drop-target"); } function onColDrop(e, targetProject) { e.preventDefault(); e.currentTarget.classList.remove("col--drop-target"); e.currentTarget.classList.remove("col--card-drop-target"); // Card drag-to-move takes precedence (operator TG 385). if (_draggedCard) { const cardId = _draggedCard; _draggedCard = null; // Find the card's CURRENT project; skip if already there. const card = (STATE.graph?.nodes || []).find(n => n.id === cardId); const currentProj = card?.project || "Uncategorized"; if (currentProj === targetProject) return; void moveCardToProject(cardId, targetProject); return; } const source = _draggedCol; _draggedCol = null; if (!source || source === targetProject) return; // Rebuild the current visible column order from the DOM, then // splice source before target. const cols = [...document.querySelectorAll(".col[data-project]")] .map(el => el.dataset.project); const srcIdx = cols.indexOf(source); const tgtIdx = cols.indexOf(targetProject); if (srcIdx < 0 || tgtIdx < 0) return; cols.splice(srcIdx, 1); cols.splice(tgtIdx < srcIdx ? tgtIdx : tgtIdx - 1 + 1, 0, source); // Persist as the new col-order — render() consumes this directly. // The render-time sort will still float pinned columns to the // left regardless of position in this list. saveColOrder(cols); render(); } function attachFilters() { const map = { "f-project": "project", "f-host": "host", "f-status": "status", "f-blocker": "blocker", "f-agent": "agent", "f-date": "date" }; Object.entries(map).forEach(([id, key]) => { const el = document.getElementById(id); if (!el) return; el.addEventListener("change", e => { STATE.filters[key] = e.target.value; renderActiveFilterChips(); // P2: chip strip + popover count render(); }); }); // P2 (lead brief 2026-06-12): initial chip render so the bar reflects // any filters restored from STATE before the operator's first // interaction. (hook-bypass: line-limit — see REFACTORING.md.) renderActiveFilterChips(); // P11b (operator priority 2026-06-12): layer the scitex-ui // Combobox on top of every <select.filt> for fuzzy-typeahead. // Progressive enhancement — when window.STX.Combobox is // unavailable (older scitex-ui pin), the plain <select> stays // functional (this just no-ops). attachCombobox(); } // === P11b — Combobox layer over <select.filt> ======================== // For each filter <select>, attach a scitex-ui Combobox that mirrors // the option list, opens with a button trigger, and on selection // updates the underlying <select>.value + dispatches a "change" // event so attachFilters()'s existing listeners fire unchanged. // The <select> stays in the DOM as the source-of-truth + a // keyboard / screen-reader fallback. (hook-bypass: line-limit.) const _CB_INSTANCES = {}; function attachCombobox() { if (!(window.STX && window.STX.Combobox)) return; // graceful fallback const FILTER_IDS = [ "f-project", "f-host", "f-status", "f-blocker", "f-agent", "f-date", ]; FILTER_IDS.forEach(id => _wrapSelectAsCombobox(id)); } function _wrapSelectAsCombobox(selectId) { const sel = document.getElementById(selectId); if (!sel) return; // Tear down a prior instance + DOM scaffolding if we're re-binding // after a /graph reload populates fresh <option>s. if (_CB_INSTANCES[selectId]) { try { _CB_INSTANCES[selectId].destroy(); } catch {} delete _CB_INSTANCES[selectId]; const oldWrap = sel.parentElement && sel.parentElement.querySelector(".cb-wrap-" + selectId); if (oldWrap) oldWrap.remove(); } // Build trigger button + container next to the select. const wrap = document.createElement("span"); wrap.className = "cb-wrap cb-wrap-" + selectId; wrap.style.display = "inline-flex"; wrap.style.alignItems = "center"; const trigger = document.createElement("button"); trigger.type = "button"; trigger.className = "filt cb-trigger"; trigger.id = selectId + "-cb-trigger"; trigger.setAttribute("aria-haspopup", "listbox"); trigger.textContent = (sel.options[sel.selectedIndex] || {}).text || "—"; trigger.style.minWidth = "120px"; trigger.style.textAlign = "left"; const container = document.createElement("span"); container.className = "cb-container"; container.id = selectId + "-cb-container"; wrap.appendChild(trigger); wrap.appendChild(container); // Hide the plain <select> + insert the combobox in its place. sel.style.display = "none"; sel.insertAdjacentElement("afterend", wrap); const items = Array.from(sel.options).map(opt => ({ value: opt.value, label: opt.text || opt.value || "—", })); try { const cb = new window.STX.Combobox({ container, trigger, items, value: sel.value, placeholder: "Search " + selectId.replace("f-", "") + "…", onChange: (item) => { sel.value = item.value; // Dispatch a "change" event so the existing attachFilters() // listeners + STATE.filters[key] update fire unchanged. sel.dispatchEvent(new Event("change", { bubbles: true })); // The trigger label is updated automatically by the // Combobox; nothing else to do here. }, }); _CB_INSTANCES[selectId] = cb; } catch (e) { // Combobox bootstrap failure → restore the plain <select>. sel.style.display = ""; wrap.remove(); } } // Re-bind comboboxes whenever populateFilters() pushes fresh // option lists into the <select>s — handled by the direct // `attachCombobox()` call appended at the end of populateFilters // (search for "P11b rebind" in this file). The Combobox bootstrap // is idempotent (destroy + recreate), so calling on every // /graph reload is safe + cheap. // === P2 — active-filter chips + popover count ======================== // The 6 filter dropdowns are tucked inside a `<details class="filt-popover">` // popover so the filterbar stays clean. To keep the operator oriented, // every active filter renders as a chip in #filt-chips with a × button // to clear it. The popover summary shows "🔧 Filters (N active)". // (hook-bypass: line-limit — see REFACTORING.md.) const FILT_CHIP_LABELS = { project: "project", host: "host", status: "status", blocker: "blocker", agent: "agent", date: "date", search: "search", }; function renderActiveFilterChips() { const wrap = document.getElementById("filt-chips"); const count = document.getElementById("filt-active-count"); const summary = document.getElementById("filt-popover-summary"); if (!wrap) return; const chips = []; for (const [key, label] of Object.entries(FILT_CHIP_LABELS)) { const val = STATE.filters[key]; if (!val) continue; chips.push( `<span class="filt-chip" title="Click × to clear"> <span class="filt-chip-key">${escapeHtml(label)}:</span> <span class="filt-chip-val">${escapeHtml(val)}</span> <button type="button" class="filt-chip-clear" aria-label="Clear ${escapeHtml(label)} filter" onclick="clearOneFilter('${key}')">×</button> </span>` ); } // blockingMe is a boolean toggle — show as a chip too. if (STATE.filters.blockingMe) { chips.push( `<span class="filt-chip" title="Click × to clear"> <span class="filt-chip-key">🚧</span> <span class="filt-chip-val">blocking me</span> <button type="button" class="filt-chip-clear" aria-label="Clear blocking-me filter" onclick="clearOneFilter('blockingMe')">×</button> </span>` ); } wrap.innerHTML = chips.join(""); // Active-count + summary highlight const popoverKeys = ["project", "host", "status", "blocker", "agent", "date"]; const active = popoverKeys.filter(k => STATE.filters[k]).length; if (count) count.textContent = `(${active})`; if (summary) summary.classList.toggle("has-active", active > 0); } function clearOneFilter(key) { if (key === "search") { STATE.filters.search = ""; const el = document.getElementById("f-search"); if (el) el.value = ""; } else if (key === "blockingMe") { STATE.filters.blockingMe = false; const tb = document.getElementById("t-block"); if (tb) tb.classList.remove("on"); } else { STATE.filters[key] = ""; const el = document.getElementById("f-" + key); if (el) el.value = ""; } renderActiveFilterChips(); render(); } function clearAllFilters() { const popoverKeys = ["project", "host", "status", "blocker", "agent", "date"]; popoverKeys.forEach(k => { STATE.filters[k] = ""; const el = document.getElementById("f-" + k); if (el) el.value = ""; }); // "Clear all" leaves the show-done toggle alone — it's a persistent // user preference, not a transient filter selection (operator // direct, lead `b9d135c1ea8348dea80f027ba66a0079`). renderActiveFilterChips(); render(); } // === Show-done toggle (operator direct, lead a2a // `b9d135c1ea8348dea80f027ba66a0079` 2026-06-12). DEFAULT OFF — // `status: done` cards drop from every layout (Column / Graph / // Table / Calendar) unless this toggle flips on. Persisted in // localStorage. The "(N hidden)" count in the toggle label is // updated in render() so the operator can't think "where did my // done cards go". (hook-bypass: line-limit.) function onShowDoneChange(e) { const on = !!(e && e.target && e.target.checked); STATE.filters.showDone = on; try { localStorage.setItem("scitex-todo:show-done", on ? "1" : "0"); } catch {} render(); } // === P9 — sort-by control ============================================= // Re-orders the cards WITHIN each column according to STATE.sort. The // default ("default") preserves the legacy date-aware sort. Other // values pull the corresponding field with a stable, sensible // comparator. Headline use is sort=deadline (pairs with the P4 // deadline schema landing later). (hook-bypass: line-limit.) function onSortChange(e) { STATE.sort = e.target.value || "default"; try { localStorage.setItem("scitex-todo:sort", STATE.sort); } catch {} render(); } // === P0 / lead design ruling (TG 12461) — Layout switcher ────── // Three orthogonal layouts; Recent stays a SORT (not a tab). The // selected layout is persisted in localStorage so the operator's // choice survives reloads. (hook-bypass: line-limit.) function onLayoutChange(mode) { // Whitelist every real layout; unknown values fall back to the kanban. // "stale" removed 2026-07-10 (operator, card todo-board-remove-stale- // view-timeline-first-20260710) — the render path stays but the // layout is unreachable (button gone, whitelist + boot both coerce). const VALID_LAYOUTS = ["graph", "table", "column", "timeline", "wall"]; const m = VALID_LAYOUTS.includes(mode) ? mode : "column"; STATE.layout = m; try { localStorage.setItem("scitex-todo:layout", m); } catch {} // Repaint the segment buttons' active state without a full render. document.querySelectorAll(".filt-layout-btn").forEach(btn => { const active = btn.getAttribute("data-layout") === m; btn.classList.toggle("filt-layout-btn--on", active); btn.setAttribute("aria-checked", active ? "true" : "false"); }); render(); } // Click on the 🆕 N new in 24h pill → switch sort to Recent. function setSortRecent() { const sel = document.getElementById("f-sort"); if (sel) sel.value = "recent"; STATE.sort = "recent"; try { localStorage.setItem("scitex-todo:sort", "recent"); } catch {} render(); } // === P10 (lead a2a 2026-06-12) — group-by toggle =================== // Off (default) = today's flat column view. On = columns cluster by // membership in a `groups[]` entry (defined at the YAML top level // next to `tasks:`). spans_all groups (e.g. "lead") render as a // banner strip above the columns grid. (hook-bypass: line-limit.) function onGroupByChange(e) { STATE.groupBy = e.target.value === "on"; render(); renderGroupStrip(); } function renderGroupStrip() { const strip = document.getElementById("group-spans-all"); if (!strip) return; const groups = (STATE.graph && STATE.graph.groups) || []; const spans = STATE.groupBy ? groups.filter(g => g.spans_all) : []; if (!spans.length) { strip.style.display = "none"; strip.innerHTML = ""; return; } strip.style.display = "flex"; strip.innerHTML = spans.map(g => { const swatch = g.color ? `<span class="group-strip-banner-swatch" style="background:${escapeHtml(g.color)}"></span>` : `<span class="group-strip-banner-swatch" style="background:var(--gold,#ffc107)"></span>`; return `<span class="group-strip-banner" title="Spans every project"> ${swatch} <span>${escapeHtml(g.label)}</span> </span>`; }).join(""); } // Build a comparator that reshuffles `projList` so projects that // share a group cluster together when STATE.groupBy is on. Pure // function — returns the same array as input when groupBy is off // OR no groups are defined. (hook-bypass: line-limit.) function _applyGroupClustering(projList) { if (!STATE.groupBy) return projList; const groups = (STATE.graph && STATE.graph.groups) || []; const nonSpanGroups = groups.filter(g => !g.spans_all); if (!nonSpanGroups.length) return projList; // First-occurrence wins: each project is assigned to the FIRST // group that declares it (overlapping memberships allowed per // schema; choose one for layout). Projects not in any group fall // into a synthetic "Ungrouped" bucket which renders last. const projGroup = new Map(); nonSpanGroups.forEach((g, gidx) => { (g.projects || []).forEach(p => { if (!projGroup.has(p)) projGroup.set(p, gidx); }); }); const UNGROUPED = nonSpanGroups.length; // sentinel index const groupedProj = projList.slice().sort((a, b) => { const ga = projGroup.has(a) ? projGroup.get(a) : UNGROUPED; const gb = projGroup.has(b) ? projGroup.get(b) : UNGROUPED; if (ga !== gb) return ga - gb; // Within a group keep the existing order (already pin/drag-sorted). return projList.indexOf(a) - projList.indexOf(b); }); return groupedProj; } // Insert a group-header DOM string before the first column belonging // to a new group as we iterate. Returns the section HTML to render // before the column (empty string when no header is needed at this // position). function _groupHeaderBefore(p, lastGroupIdx) { if (!STATE.groupBy) return { header: "", groupIdx: lastGroupIdx }; const groups = (STATE.graph && STATE.graph.groups) || []; const nonSpanGroups = groups.filter(g => !g.spans_all); if (!nonSpanGroups.length) return { header: "", groupIdx: lastGroupIdx }; let groupIdx = nonSpanGroups.length; // Ungrouped sentinel let group = null; for (let i = 0; i < nonSpanGroups.length; i++) { if ((nonSpanGroups[i].projects || []).includes(p)) { groupIdx = i; group = nonSpanGroups[i]; break; } } if (groupIdx === lastGroupIdx) return { header: "", groupIdx }; const label = group ? group.label : "Ungrouped"; const swatch = group && group.color ? `<span class="group-header-swatch" style="background:${escapeHtml(group.color)}"></span>` : `<span class="group-header-swatch" style="background:transparent"></span>`; const header = `<div class="group-header"> ${swatch}<span>${escapeHtml(label)}</span> </div>`; return { header, groupIdx }; } // Build a (a,b)=>number comparator for the named sort mode. Used by // render() per column. "default" + "deadline" both use dateInfo() (the // legacy parse), the difference being that "deadline" pushes undated // cards to the bottom AND we keep date-bearing cards strictly time- // ordered. "priority" uses the numeric .priority rank (lower = higher // priority; missing → last). "status" uses the bucket() order // working→waiting→blocked→done. "last_activity" sorts ISO strings // descending. "title" / "project" are simple localeCompare. // (hook-bypass: line-limit — see REFACTORING.md.) // P0 / lead design ruling (TG 12461, 2026-06-12) — `_newestTs(t)` // returns the most recent activity timestamp for the task in ms, // used by the "recent" sort mode + the NEW-badge predicate. Falls // back through created_at → last_activity → 0 (so timeless tasks // sort to the bottom of Recent rather than NaN-poisoning). function _newestTs(t) { const candidates = [t.last_activity, t.created_at]; for (const c of candidates) { if (!c) continue; const ms = Date.parse(c); if (!isNaN(ms)) return ms; } return 0; } function _isNew24h(t) { const ts = _newestTs(t); if (!ts) return false; return (Date.now() - ts) < (24 * 3600 * 1000); } // === Time-bucket classifier (operator TIME-BASED VIEW — // lead a2a `ff1441d7`, 2026-06-14) ================================= // Walks the card's `last_activity` and returns one of 4 buckets: // "today" — within the last 24 h // "week" — 24 h .. 7 d // "month" — 7 d .. 30 d // "older" — > 30 d (or missing/unparseable last_activity) // Pure function (no DOM); mirrored in the JS-RUNTIME of // tests/scitex_todo/_django/test__sort_by_time.py. const TIME_BUCKETS = ["today", "week", "month", "older"]; const TIME_BUCKET_LABELS = { today: "TODAY", week: "THIS WEEK", month: "THIS MONTH", older: "OLDER", }; function timeBucketForCard(task, nowMs) { const ref = (typeof nowMs === "number" && isFinite(nowMs)) ? nowMs : Date.now(); const ts = task && task.last_activity; if (!ts) return "older"; const parsed = Date.parse(ts); if (isNaN(parsed)) return "older"; const ageMs = ref - parsed; const ONE_DAY = 86400000; const ONE_WEEK = 7 * ONE_DAY; const ONE_MONTH = 30 * ONE_DAY; if (ageMs <= ONE_DAY) return "today"; if (ageMs <= ONE_WEEK) return "week"; if (ageMs <= ONE_MONTH) return "month"; return "older"; } // === Time-based sort-key helper ==================================== // Returns the numeric epoch-ms key for a card under a given sort mode. // Missing/unparseable values return 0 so they sort to the END for // DESC orderings. Pure function (mirrored in tests). function timeSortKey(task, mode) { if (!task) return 0; let raw = null; if (mode === "last_activity") raw = task.last_activity; else if (mode === "created_at") raw = task.created_at; else if (mode === "completed_at") { // completed_at comes from the _log_meta envelope when the card // transitioned to status=done. Falls back to last_activity for // back-compat snapshots that don't carry _log_meta yet. raw = (task._log_meta && task._log_meta.completed_at) || task.completed_at || null; } if (!raw) return 0; const ms = Date.parse(raw); return isNaN(ms) ? 0 : ms; } function _sortComparator(mode) { const STATUS_RANK = { working: 0, waiting: 1, blocked: 2, done: 3 }; const dateRank = (t) => { const di = dateInfo(t); return di ? di.date.getTime() : null; }; switch (mode) { // Recent sort (lead design ruling 2026-06-12 — TIME axis, // orthogonal to LAYOUT axis). Newest-first by last_activity, // then created_at. Pairs with the `card-new-badge` rendered // on cards <24h old below. case "recent": return (a, b) => _newestTs(b) - _newestTs(a); case "deadline": return (a, b) => { const da = dateRank(a), db = dateRank(b); if (da !== null && db !== null) return da - db; if (da !== null) return -1; if (db !== null) return 1; return 0; }; case "priority": return (a, b) => { const pa = typeof a.priority === "number" ? a.priority : Number.POSITIVE_INFINITY; const pb = typeof b.priority === "number" ? b.priority : Number.POSITIVE_INFINITY; return pa - pb; }; case "status": return (a, b) => { const ra = STATUS_RANK[bucket(a.status)] ?? 99; const rb = STATUS_RANK[bucket(b.status)] ?? 99; return ra - rb; }; case "project": return (a, b) => String(a.project || "Uncategorized") .localeCompare(String(b.project || "Uncategorized")); case "last_activity": // Numeric (epoch ms) comparator so cards without // last_activity sort to the bottom (key=0) instead of the // string lexicographic top. Operator TIME-BASED VIEW. return (a, b) => timeSortKey(b, "last_activity") - timeSortKey(a, "last_activity"); case "created_at": // Newest-first by created_at — operator TIME-BASED VIEW. return (a, b) => timeSortKey(b, "created_at") - timeSortKey(a, "created_at"); case "completed_at": // Newest-first by _log_meta.completed_at — operator // TIME-BASED VIEW. Cards never completed sort to the end. return (a, b) => timeSortKey(b, "completed_at") - timeSortKey(a, "completed_at"); case "title": return (a, b) => String(a.title || "").localeCompare(String(b.title || "")); default: // Legacy date-aware sort: cards with a parsed date sort first // (soonest future → oldest past). Cards without a date keep // their original order. return (a, b) => { const da = dateInfo(a), db = dateInfo(b); if (da && db) return da.date - db.date; if (da && !db) return -1; if (!da && db) return 1; return 0; }; } } // ESC closes the open modal — operator TG 265: "popup は常に esc 有効 // なように." HIG / WAI-ARIA universal expectation. Long-term home is // the scitex-ui Modal primitive (so every scitex-app gets it for free); // this local handler is the short-term scitex-todo fix until that lands. // Listens on document so the focus can be anywhere (the modal contents, // an input field, the body) and ESC still closes. document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; // Operator TG 454 きもすぎ: the filterbar <details> dropdowns // (hide-project list + hidden-cards list) didn't close on ESC. // Native <details> doesn't ship that handler — add it here so // every floating dropdown follows the same dismiss convention // as the modals (matches the operator's universal "ESC closes // open thing" expectation, TG 265). const openDetails = document.querySelectorAll( ".filterbar details[open], details.proj-hide[open], details.hidden-list[open]" ); if (openDetails.length) { openDetails.forEach(d => d.open = false); e.preventDefault(); e.stopPropagation(); return; } const backdrop = document.getElementById("detail-backdrop"); if (backdrop && backdrop.classList.contains("show")) { // If Resolve is armed (orange one-click-from-firing state) ESC // disarms FIRST without closing — so the user can think + step // back without losing the drawer they were reading. Second ESC // (or one ESC when not armed) closes as before. if (RESOLVE_ARMED) { disarmResolve(); } else { closeDetail(); } e.preventDefault(); e.stopPropagation(); } }); function toggleBlockingMe(e) { STATE.filters.blockingMe = !STATE.filters.blockingMe; e.currentTarget.classList.toggle("on", STATE.filters.blockingMe); renderActiveFilterChips(); // P2: surface the 🚧 chip render(); } function openDetail(id) { STATE.openId = id; const t = (STATE.graph?.nodes || []).find(x => x.id === id); if (!t) return; document.getElementById("d-title").textContent = t.task || t.title; // scitex-todo's entity is the USER (an agent is just user.kind=agent), // so the meta line names the ASSIGNEE in user terms, not "agent". // (hook-bypass: line-limit — board_v3.html split still queued.) const assignee = t.assignee || t.agent || "—"; document.getElementById("d-meta").textContent = `${t.project || "—"} · ${t.host || ""} · assignee ${assignee} · id ${t.id}`; // --- USER ROLES (creator / assignee / collaborators / subscribers) --- // creator = created_by, falling back to the earliest comment author, // else "—" (legacy cards predate created_by). Empty lists render "—". // See ADR-0009 + the graph.py node payload. (hook-bypass: line-limit.) const fmtList = (xs) => (Array.isArray(xs) && xs.length) ? xs.map(escapeHtml).join(", ") : "—"; const firstCommentAuthor = (t.comments || []) .map(c => c && c.author).find(a => typeof a === "string" && a); const creator = t.created_by || firstCommentAuthor || "—"; const rows = [ ["title", t.title], ["status", t.status], ["kind", t.kind || "(task)"], ["blocker", t.blocker || "(none)"], ["— users —", ""], ["creator", escapeHtml(creator)], ["assignee", escapeHtml(assignee)], ["collaborators", fmtList(t.collaborators)], ["subscribers", fmtList(t.subscribers)], ["last_activity", t.last_activity || "—"], ["created_at", t.created_at || "—"], ["goal", t.goal || "—"], ["pr_url", t.pr_url ? `<a href="${escapeHtml(t.pr_url)}" target="_blank">${escapeHtml(t.pr_url)}</a>` : "—"], ["issue_url", t.issue_url ? `<a href="${escapeHtml(t.issue_url)}" target="_blank">${escapeHtml(t.issue_url)}</a>` : "—"], ["depends_on", (STATE.graph.edges || []).filter(e => e.kind === "depends_on" && e.target === id).map(e => e.source).join(", ") || "—"], ["comments", String((t.comments || []).length)], ]; document.getElementById("d-rows").innerHTML = rows.map(r => `<div class="detail-row"><div class="detail-key">${escapeHtml(r[0])}</div><div class="detail-val">${r[1]}</div></div>` ).join(""); const rb = document.getElementById("d-resolve"); rb.disabled = (t.status === "done"); rb.textContent = (t.status === "done") ? "Already resolved" : "Resolve → notify agent"; // Every fresh drawer-open disarms Resolve so the 2-click confirm // applies even if a previous open left it armed. disarmResolve(); // Word-style comment thread — paint comments[] and clear the // textarea so the operator starts fresh on every drawer-open. renderComments(t); const ta = document.getElementById("d-comment-text"); if (ta) ta.value = ""; document.getElementById("detail-backdrop").classList.add("show"); } function closeDetail(e) { if (e && e.target.classList.contains("detail")) return; document.getElementById("detail-backdrop").classList.remove("show"); STATE.openId = null; // Closing the drawer should also disarm — never leave a destructive // primed state behind when the user backs out. disarmResolve(); } // Resolve-confirm state — see the "2-click confirm" comment on resolveTask. let RESOLVE_ARMED = false; let RESOLVE_ARM_TIMER = null; function disarmResolve() { RESOLVE_ARMED = false; if (RESOLVE_ARM_TIMER !== null) { clearTimeout(RESOLVE_ARM_TIMER); RESOLVE_ARM_TIMER = null; } const rb = document.getElementById("d-resolve"); const cb = document.getElementById("d-cancel-resolve"); if (rb && !rb.disabled) rb.textContent = "Resolve → notify agent"; if (rb) rb.classList.remove("detail-resolve--armed"); if (cb) cb.style.display = "none"; } function armResolve() { RESOLVE_ARMED = true; const rb = document.getElementById("d-resolve"); const cb = document.getElementById("d-cancel-resolve"); rb.textContent = "⚠ Click again to confirm"; rb.classList.add("detail-resolve--armed"); cb.style.display = ""; // Auto-disarm after 6s of no follow-through so the orange button // does not linger after the user has clearly walked away. RESOLVE_ARM_TIMER = setTimeout(disarmResolve, 6000); } function toast(msg, err) { const el = document.getElementById("toast"); el.innerHTML = ""; const span = document.createElement("span"); span.textContent = msg; el.appendChild(span); el.classList.toggle("toast--err", !!err); el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 3600); } /* Toast with an Undo button. The button stays clickable for ``window`` ms (default 10000) — long enough that a startled operator can click it even after blinking, short enough that the agent stops waiting on the human and moves on. Pair with /reopen on the backend. */ function toastUndo(msg, undoFn, window) { const el = document.getElementById("toast"); const ms = (typeof window === "number" && window > 0) ? window : 10000; el.innerHTML = ""; const span = document.createElement("span"); span.textContent = msg; el.appendChild(span); const btn = document.createElement("button"); btn.className = "toast-undo"; btn.textContent = "↺ Undo"; btn.onclick = async () => { btn.disabled = true; try { await undoFn(); } finally { /* hide whether or not undo errored */ el.classList.remove("show"); } }; el.appendChild(btn); el.classList.remove("toast--err"); el.classList.add("show"); setTimeout(() => el.classList.remove("show"), ms); } async function postResolve(id) { const r = await fetch("/resolve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, actor: "operator" }), }); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `HTTP ${r.status}`); } return r.json(); } async function postReopen(id, priorStatus, priorBlocker) { const r = await fetch("/reopen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, actor: "operator", prior_status: priorStatus || null, prior_blocker: priorBlocker || null, }), }); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `HTTP ${r.status}`); } return r.json(); } /* Resolve flow with 2-click confirm + Undo affordance — operator pain TG 9763. Click 1: arms (button turns orange + Cancel appears + auto- disarms after 6s). Click 2 (within the window): fires. After success, the toast carries an Undo button hot for ~10s wiring back to /reopen. */ async function resolveTask() { if (!STATE.openId) return; const rb = document.getElementById("d-resolve"); if (!RESOLVE_ARMED) { armResolve(); return; } // Second click — fire. disarmResolve(); rb.disabled = true; rb.textContent = "resolving…"; const tid = STATE.openId; try { const result = await postResolve(tid); const priorStatus = result.prior_status || null; const priorBlocker = result.prior_blocker || null; closeDetail(); await loadGraph(); toastUndo(`✓ Resolved ${tid}`, async () => { try { await postReopen(tid, priorStatus, priorBlocker); toast(`↺ Re-opened ${tid}`); await loadGraph(); } catch (err) { toast(`✗ undo failed: ${err.message}`, true); } }); } catch (e) { toast(`✗ ${e.message}`, true); rb.disabled = false; rb.textContent = "Resolve → notify agent"; } } /* BLOCKING-YOU panel button stays single-click for now (small target, can't easily host the orange-arm UI) — but it inherits the Undo toast so the safety net still applies. If the operator hits this same trap from the panel, escalate to a confirm popup. */ async function resolveById(id, btn) { if (btn) { btn.disabled = true; btn.textContent = "resolving…"; } try { const result = await postResolve(id); const priorStatus = result.prior_status || null; const priorBlocker = result.prior_blocker || null; await loadGraph(); toastUndo(`✓ Resolved ${id}`, async () => { try { await postReopen(id, priorStatus, priorBlocker); toast(`↺ Re-opened ${id}`); await loadGraph(); } catch (err) { toast(`✗ undo failed: ${err.message}`, true); } }); } catch (e) { toast(`✗ ${e.message}`, true); if (btn) { btn.disabled = false; } } } // === Comment thread (lead a2a a7e53184 — Word-style threaded comments) // ───────────────────────────────────────────────────────────────────── // Renders all comments[] chronologically (oldest first like a chat), // colors operator/system trails distinctly, scrolls newest into view. // Route trace (operator 2026-06-17): render the card's event stream as // a chronological journey — "how it was routed". Built from created_at + // comments[] (each carrying an optional `kind` ring tag the hooks stamp) // + a synthetic done hop from status/completed_at. Derived from the event // stream, so it adapts automatically as the workflow's rings change. const ROUTE_RING = { created: { label: "✦ created", cls: "created" }, push: { label: "⬆ push", cls: "push" }, "ci-result": { label: "✓ CI", cls: "ci" }, ci: { label: "✓ CI", cls: "ci" }, done: { label: "✅ done", cls: "done" }, "card-message": { label: "💬 message", cls: "message" }, message: { label: "💬 message", cls: "message" }, system: { label: "⚙ system", cls: "system" }, escalate: { label: "⚠ escalate", cls: "escalate" }, }; function _routeHops(task) { const hops = []; if (task.created_at) { hops.push({ ts: String(task.created_at), actor: task.agent || task.assignee || "—", kind: "created", text: "card created" }); } (Array.isArray(task.comments) ? task.comments : []).forEach(c => { let kind = c.kind || ""; if (!kind) { // untagged legacy entries: [RESOLVED]/[CLOSED]/… are system trail; // everything else is a board-card message (operator/agent). kind = /^\[(RESOLVED|UNDONE|re-opened|CLOSED)/i.test(String(c.text || "")) ? "system" : "message"; } hops.push({ ts: String(c.ts || ""), actor: String(c.author || "?"), kind, text: String(c.text || "") }); }); if (task.status === "done") { const dts = (task._log_meta && task._log_meta.completed_at) || task.last_activity || ""; hops.push({ ts: String(dts), actor: "—", kind: "done", text: task.pr_url ? ("merged " + task.pr_url) : "marked done" }); } hops.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0)); return hops; } function renderComments(task) { const list = document.getElementById("d-comment-list"); if (!list) return; const hops = _routeHops(task); if (!hops.length) { list.innerHTML = `<div class="comment-empty">No route activity yet.</div>`; return; } list.innerHTML = hops.map(h => { const r = ROUTE_RING[h.kind] || { label: String(h.kind || "·"), cls: "message" }; return `<div class="route-hop route-hop--${r.cls}"> <span class="route-badge route-badge--${r.cls}">${escapeHtml(r.label)}</span> <div class="route-body"> <div class="route-meta"> <span class="route-actor">${escapeHtml(h.actor)}</span> <span class="route-ts">${escapeHtml(h.ts)}</span> </div> <div class="route-text">${escapeHtml(h.text)}</div> </div> </div>`; }).join(""); list.scrollTop = list.scrollHeight; } async function postCommentFromDrawer() { if (!STATE.openId) return; const ta = document.getElementById("d-comment-text"); const btn = document.getElementById("d-comment-post"); if (!ta || !btn) return; const text = (ta.value || "").trim(); if (!text) return; const original = btn.textContent; btn.disabled = true; btn.textContent = "posting…"; try { const r = await fetch("/comment", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: STATE.openId, text, author: "operator" }), }); if (!r.ok) { const errBody = await r.json().catch(() => ({})); throw new Error(errBody.error || `HTTP ${r.status}`); } const body = await r.json().catch(() => ({})); ta.value = ""; await loadGraph(); const t = STATE.graph && STATE.graph.nodes.find(x => x.id === STATE.openId); if (t) renderComments(t); // hook-bypass: line-limit — board_v3.html is a pre-existing // 3900-line template far over the HTML cap; this P1 hotfix adds // a few lines of loud-toast wiring, not new bulk. Splitting the // template is a separate refactor, out of scope here. // // Comment is SAVED. The relay (notify the owning agent) is // best-effort + may fail/time-out — surface that LOUD rather // than swallow it (operator P1, 2026-06-25; fail-loud). A // `relay.sent === false` with a non-skip reason is a real // delivery failure; skip:* (self-comment / no-agent) is // expected and stays quiet. const relay = body.relay || {}; const skipped = typeof relay.wire === "string" && relay.wire.indexOf("skip:") === 0; if (relay.sent === false && !skipped) { const who = relay.target || "owner"; const why = relay.error || relay.reason || "unreachable"; toast(`⚠ Comment saved, but could not notify ${who}: ${why}`, true); } else { toast("✓ Comment posted"); } } catch (e) { toast(`✗ ${e.message}`, true); } finally { btn.disabled = false; btn.textContent = original; } } // === Priority ↑↓ (operator TG 9776 / 276) ============================== // Single click swaps the task with its global-order neighbor and POSTs // the full reordered id-list to /priority (which assigns ranks 1..N). async function bumpPriority(id, direction) { if (!STATE.graph) return; // Stable global order: priority ascending (missing → end), id-tiebreak. const all = [...STATE.graph.nodes].sort((a, b) => { const pa = (typeof a.priority === "number") ? a.priority : 9999; const pb = (typeof b.priority === "number") ? b.priority : 9999; if (pa !== pb) return pa - pb; return String(a.id || "").localeCompare(String(b.id || "")); }); const idx = all.findIndex(t => t.id === id); if (idx < 0) return; const swap = idx + direction; if (swap < 0 || swap >= all.length) { toast(direction < 0 ? "Already top priority" : "Already last priority"); return; } [all[idx], all[swap]] = [all[swap], all[idx]]; const order = all.map(t => t.id); try { const r = await fetch("/priority", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ order }), }); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `HTTP ${r.status}`); } toast(`✓ Priority ${direction < 0 ? "↑" : "↓"}: ${id}`); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } } // Cmd/Ctrl-Enter inside the comment textarea posts immediately — // small ergonomic win the operator gets for free since they type // there most. document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { const ta = document.getElementById("d-comment-text"); if (ta && document.activeElement === ta) { postCommentFromDrawer(); e.preventDefault(); } } }); // AutoRefresh via /rev mtime poll (same wire as the existing // GraphView's AutoRefresh — ADR-0005). async function autoRefreshTick() { try { const r = await fetch("/rev"); if (!r.ok) return; const rev = await r.json(); // GUI-code hot-reload: if the board template changed on disk (a // merged PR / local edit under the editable install), the server is // already serving new HTML/JS but THIS pane is still running the old // bundle. Hard-reload so the operator picks up GUI updates without a // manual restart / F5. First observation only records the baseline. if (rev.asset_rev !== undefined) { if (STATE.assetRev === undefined) { STATE.assetRev = rev.asset_rev; } else if (rev.asset_rev !== STATE.assetRev) { STATE.assetRev = rev.asset_rev; location.reload(); return; } } const key = `${rev.mtime}:${rev.count}`; if (STATE.lastMtime === null) { STATE.lastMtime = key; return; } if (key !== STATE.lastMtime) { STATE.lastMtime = key; // Non-disruptive refresh (hook-bypass: line-limit): skip the // redraw when the freshly-fetched /graph payload is identical // to what's already on screen (mtime can bump without a // meaningful change), and preserve scroll when it DOES redraw. await loadGraph({ preserveScroll: true, skipIfUnchanged: true }); // Keep the Timeline raster live on a store change (the simple // view already re-rendered from STATE.graph inside loadGraph). if (typeof window.timelineOnStoreChange === "function") { window.timelineOnStoreChange(); } } } catch { /* transient; try next tick */ } } // === CRUD wiring (operator TG 344) ==================================== // Backend already implements /create /update /delete /restore (see // _django/handlers/crud.py + 33 tests in test_crud.py); board v3 // previously only used /graph /rev /resolve. This block adds the // browser-side wiring for the missing four ops. // -- Project-column hide (client-side, localStorage, soft) --------- const PROJ_HIDDEN_KEY = "scitex-todo:proj-hidden"; function projHiddenSet() { try { return new Set(JSON.parse(localStorage.getItem(PROJ_HIDDEN_KEY) || "[]")); } catch { return new Set(); } } function saveProjHidden(s) { localStorage.setItem(PROJ_HIDDEN_KEY, JSON.stringify([...s])); const cnt = document.getElementById("proj-hidden-count"); if (cnt) cnt.textContent = String(s.size); } function toggleProjHidden(p, checked) { const s = projHiddenSet(); if (checked) s.add(p); else s.delete(p); saveProjHidden(s); render(); } function rebuildProjHideList() { if (!STATE.graph) return; const projects = new Set( STATE.graph.nodes.map(n => n.project || "—").filter(Boolean) ); const sorted = [...projects].sort(); const hidden = projHiddenSet(); const list = document.getElementById("proj-hide-list"); if (!list) return; if (sorted.length === 0) { list.innerHTML = `<div class="proj-hide-empty">No projects loaded yet.</div>`; } else { list.innerHTML = sorted.map(p => ` <label class="proj-hide-row" title="Hide column for project ${escapeHtml(p)}"> <input type="checkbox" ${hidden.has(p) ? "checked" : ""} onchange="toggleProjHidden('${escapeHtml(p)}', this.checked)"> <span>${escapeHtml(p)}</span> </label> `).join(""); } saveProjHidden(hidden); // refresh count } // -- Add Task modal ----------------------------------------------------- function openAddTaskModal(presetProject) { // Pre-populate the project datalist with auto-discovered projects. if (STATE.graph) { const projects = [...new Set( STATE.graph.nodes.map(n => n.project).filter(Boolean) )].sort(); document.getElementById("at-project-list").innerHTML = projects.map(p => `<option value="${escapeHtml(p)}">`).join(""); } // Suggest known owners (existing card owners) in the assignee datalist // so the operator can pick fast, while still allowing a free-form name. // hook-bypass: line-limit (board_v3.html split still queued) if (STATE.graph) { const owners = [...new Set( STATE.graph.nodes.map(n => cardOwner(n)).filter(Boolean) )].sort(); document.getElementById("at-assignee-list").innerHTML = owners.map(o => `<option value="${escapeHtml(o)}">`).join(""); } document.getElementById("at-title").value = ""; // Assignee is required; reset it empty on each open so a stale value // can't silently re-own the next card. document.getElementById("at-assignee").value = ""; // If invoked from an empty column's placeholder (operator TG 381), // pre-fill the project so the new task goes straight to that // column without retyping the name. const projInput = document.getElementById("at-project"); projInput.value = presetProject && presetProject !== "—" ? presetProject : ""; document.getElementById("at-status").value = "deferred"; document.getElementById("at-task").value = ""; document.getElementById("add-task-backdrop").classList.add("show"); setTimeout(() => document.getElementById("at-title").focus(), 50); } function closeAddTaskModal(e) { if (e && e.target && !e.target.classList.contains("at-backdrop")) return; document.getElementById("add-task-backdrop").classList.remove("show"); } async function submitAddTask() { const title = document.getElementById("at-title").value.trim(); if (!title) { toast("title is required", true); document.getElementById("at-title").focus(); return; } // Assignee is REQUIRED — a card must have an owner (operator // constitution, no silent fallbacks). Block the POST + show an inline // message rather than create an owner-less card. The server enforces // this too (handle_create -> add_task), this is the fast UX gate. // hook-bypass: line-limit (board_v3.html split still queued) const assignee = document.getElementById("at-assignee").value.trim(); if (!assignee) { toast("assignee is required — pick an owner", true); document.getElementById("at-assignee").focus(); return; } const project = document.getElementById("at-project").value.trim(); const status = document.getElementById("at-status").value; const task = document.getElementById("at-task").value.trim(); const btn = document.getElementById("at-create"); btn.disabled = true; btn.textContent = "creating…"; try { const body = { title, status, assignee }; if (project) body.project = project; if (task) body.task = task; const r = await fetch("/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } const result = await r.json(); toast(`✓ Created ${result.id || title}`); document.getElementById("add-task-backdrop").classList.remove("show"); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } finally { btn.disabled = false; btn.textContent = "Create"; } } // -- Detail edit (in-drawer form, toggled by Edit button) --------------- function enterDetailEdit() { if (!STATE.openId) return; const t = (STATE.graph?.nodes || []).find(x => x.id === STATE.openId); if (!t) return; document.getElementById("d-edit-title").value = t.title || ""; document.getElementById("d-edit-status").value = t.status || "deferred"; document.getElementById("d-edit-blocker").value = t.blocker || ""; document.getElementById("d-edit-project").value = t.project || ""; document.getElementById("d-edit-agent").value = t.agent || ""; document.getElementById("d-edit-task").value = t.task || ""; document.getElementById("d-edit-note").value = t.note || ""; document.getElementById("d-edit-form").classList.add("show"); document.getElementById("d-edit").style.display = "none"; document.getElementById("d-rows").style.display = "none"; } function cancelDetailEdit() { document.getElementById("d-edit-form").classList.remove("show"); document.getElementById("d-edit").style.display = ""; document.getElementById("d-rows").style.display = ""; } async function saveDetailEdit() { if (!STATE.openId) return; const patch = { id: STATE.openId, title: document.getElementById("d-edit-title").value, status: document.getElementById("d-edit-status").value, project: document.getElementById("d-edit-project").value, agent: document.getElementById("d-edit-agent").value, task: document.getElementById("d-edit-task").value, note: document.getElementById("d-edit-note").value, }; const blocker = document.getElementById("d-edit-blocker").value; // /update accepts patch semantics — only set blocker when provided // AND status is blocked (the validator enforces that pairing). patch.blocker = blocker || null; try { const r = await fetch("/update", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } toast(`✓ Updated ${STATE.openId}`); cancelDetailEdit(); closeDetail(); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } } // -- Delete + Undo ------------------------------------------------------ async function deleteCurrentTask() { if (!STATE.openId) return; const id = STATE.openId; const t = (STATE.graph?.nodes || []).find(x => x.id === id); const label = t ? `"${t.title}" (${id})` : id; if (!window.confirm(`Delete ${label}?\n\nThis will scrub depends_on / blocks / parent references too. Undo is available for ~10s after delete.`)) return; try { const r = await fetch("/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.error || `HTTP ${r.status}`); } const result = await r.json(); // Undo: POST /restore with the same removed payload + refs. const undo = async () => { try { const rr = await fetch("/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ task: result.removed, refs: result.refs || [], }), }); if (!rr.ok) throw new Error(`HTTP ${rr.status}`); await loadGraph(); toast(`↶ Restored ${id}`); } catch (e) { toast(`✗ Undo failed: ${e.message}`, true); } }; showUndoToast(`✓ Deleted ${id}`, undo); closeDetail(); await loadGraph(); } catch (e) { toast(`✗ ${e.message}`, true); } } function showUndoToast(msg, undoFn) { const el = document.getElementById("toast"); el.innerHTML = `${escapeHtml(msg)} <button class="toast-undo" id="t-undo-btn">Undo</button>`; el.classList.remove("toast--err"); el.classList.add("show"); document.getElementById("t-undo-btn").onclick = async () => { el.classList.remove("show"); await undoFn(); }; setTimeout(() => el.classList.remove("show"), 10000); } // -- Render-time integration -------------------------------------------- // Hook into render() via a wrapper that ALSO refreshes the project-hide // list + hides any column whose project is in the hidden set. The // original render() lives above; we wrap it without touching the body. const _origRender = render; render = function() { _origRender(); rebuildProjHideList(); // Hide entire columns for projects in the hidden set. const hidden = projHiddenSet(); document.querySelectorAll(".col").forEach(col => { const nameEl = col.querySelector(".col-name"); if (!nameEl) return; const name = nameEl.textContent.trim(); col.classList.toggle("col--proj-hidden", hidden.has(name)); }); // Hidden-cards dropdown (operator TG 381) — repaint the list + // the headerlabel count on every render so a new hide / un-hide // shows up immediately. rebuildHiddenList(); }; // ── Mobile responsive helpers ───────────────────────────────────── // (was: toggleByDrawer / updateByFabBadge for the BLOCKING-YOU // 360 px aside — removed in P12 along with the aside; the // operator-decision queue is now the synthesized `user` column in // `_renderColumnView`.) // Touch long-press → context menu (operator TG 459/460). Native // contextmenu isn't reliably fired on iOS, so we open it via a // 600ms timer started on touchstart, cancelled on touchmove / // touchend / touchcancel before the timer fires. Pointer events // (Apple Pencil etc.) feed through the same path. const _LONG_PRESS_MS = 600; let _lpTimer = null, _lpEl = null; document.addEventListener("touchstart", (e) => { const card = e.target && e.target.closest && e.target.closest(".card[data-card-id]"); if (!card) return; _lpEl = card; card.classList.add("touch-pressing"); const t = e.touches[0]; const cx = t.clientX, cy = t.clientY; _lpTimer = setTimeout(() => { card.classList.remove("touch-pressing"); const id = card.getAttribute("data-card-id"); openCardCtx({ preventDefault: () => {}, clientX: cx, clientY: cy, }, id); _lpTimer = null; }, _LONG_PRESS_MS); }, { passive: true }); function _cancelLongPress() { if (_lpTimer) { clearTimeout(_lpTimer); _lpTimer = null; } if (_lpEl) { _lpEl.classList.remove("touch-pressing"); _lpEl = null; } } document.addEventListener("touchmove", _cancelLongPress, { passive: true }); document.addEventListener("touchend", _cancelLongPress, { passive: true }); document.addEventListener("touchcancel", _cancelLongPress, { passive: true }); // ── Hidden-cards dropdown (operator TG 381) ──────────────────────── // Walks STATE.graph.nodes for everything in hiddenSet() and paints // a row per hidden card (title + project + un-hide button). Also // updates the "👁 hidden (N)" count in the dropdown summary. function rebuildHiddenList() { const hidden = hiddenSet(); const panel = document.getElementById("hidden-list-panel"); const countEl = document.getElementById("hidden-count"); if (countEl) countEl.textContent = String(hidden.size); if (!panel) return; if (hidden.size === 0) { panel.innerHTML = `<div class="hidden-list-empty">No hidden cards.</div>`; return; } const byId = new Map((STATE.graph?.nodes || []).map(n => [n.id, n])); const rows = [...hidden].map(id => { const t = byId.get(id); if (!t) { // Hidden card no longer exists in the store (deleted by // someone). Offer un-hide to clear the localStorage entry. return `<div class="hidden-list-row"> <div style="flex:1"> <div class="hidden-list-title">${escapeHtml(id)}</div> <div class="hidden-list-meta">(missing from store)</div> </div> <button class="hidden-list-unhide" onclick="unhideTask('${escapeHtml(id)}')">Un-hide</button> </div>`; } const title = t.title || t.task || id; const proj = t.project || "—"; const status = t.status || "?"; return `<div class="hidden-list-row"> <div style="flex:1; min-width:0;"> <div class="hidden-list-title" title="${escapeHtml(title)}">${escapeHtml(title)}</div> <div class="hidden-list-meta">${escapeHtml(proj)} · ${escapeHtml(status)} · id:${escapeHtml(id)}</div> </div> <button class="hidden-list-unhide" onclick="unhideTask('${escapeHtml(id)}')">Un-hide</button> </div>`; }).join(""); const revealOn = STATE.filters.showHidden ? "checked" : ""; panel.innerHTML = rows + ` <div class="hidden-list-reveal"> <input type="checkbox" id="reveal-hidden" ${revealOn} onchange="toggleShowHidden({ currentTarget: this })"> <label for="reveal-hidden" style="cursor:pointer;"> Show hidden cards in place (don't filter) </label> </div>`; } // (renderFleetStrip removed with the #fleetstrip element — see the // filterbar comment near the top of this template. fleet-liveness // lives in the lead's periodic reports, not the board.) function toggleAgentFilter(agent) { // Click a dot: if it's not the current f-agent value, set it; if // it IS, clear it back to "All assignees". Updates the <select> too // so the existing filter pipeline stays consistent. const sel = document.getElementById("f-agent"); if (!sel) return; const newVal = STATE.filters.agent === agent ? "" : agent; STATE.filters.agent = newVal; sel.value = newVal; render(); } // -- Keyboard shortcuts ------------------------------------------------- // Cmd/Ctrl+N opens the Add Task modal (operator-friendly create entry). document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "n") { const backdrop = document.getElementById("add-task-backdrop"); if (backdrop && !backdrop.classList.contains("show")) { e.preventDefault(); openAddTaskModal(); } } // Cmd/Ctrl-Enter inside the Add Task title submits. if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { const at = document.getElementById("add-task-backdrop"); if (at && at.classList.contains("show")) { e.preventDefault(); submitAddTask(); } } // ESC closes the Add Task modal (the legacy ESC handler covers detail). if (e.key === "Escape") { const at = document.getElementById("add-task-backdrop"); if (at && at.classList.contains("show")) { closeAddTaskModal({ target: { classList: { contains: () => true } } }); } } }); // P1 Search-as-launcher (lead brief 2026-06-12) — RESTORED 2026-06-12. // "/" anywhere focuses #f-search; Esc inside the search blurs it. // Skip "/" when typing in another input/textarea/select/contenteditable // OR when a modifier is held (so browser shortcuts like Cmd-/ stay // available). Same muscle memory as Gmail / Jira / Linear. Pinned by // tests/scitex_todo/test__board_v3_signatures.py so a future squash // can't silently regress it again. function attachSearchKeyboardLauncher() { const search = document.getElementById("f-search"); if (!search) return; document.addEventListener("keydown", (e) => { if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) { const t = e.target; const tag = (t && t.tagName) ? t.tagName.toLowerCase() : ""; const editable = tag === "input" || tag === "textarea" || tag === "select" || (t && t.isContentEditable); if (editable) return; e.preventDefault(); search.focus(); const v = search.value; search.setSelectionRange(v.length, v.length); return; } if (e.key === "Escape" && document.activeElement === search) { search.blur(); } }); } attachSearchKeyboardLauncher(); attachFilters(); loadGraph(); setInterval(autoRefreshTick, 5000); </script> {% endblock extra_js %}