trackops 2.0.5 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -278,11 +278,16 @@ function _renderCard(task, phases) {
278
278
  <div style="display:flex;justify-content:space-between;align-items:start;gap:var(--space-2, 0.5rem)">
279
279
  <span class="scrum-card-title" style="font-size:var(--text-sm, 0.875rem);font-weight:500;color:var(--text-primary);line-height:1.3">${esc(task.title)}</span>
280
280
  <span class="badge badge-${priorityVariant[task.priority] || 'muted'}" style="flex-shrink:0">${esc(task.priority)}</span>
281
- </div>
282
- <div class="scrum-card-meta" style="display:flex;flex-wrap:wrap;gap:var(--space-1, 0.25rem);margin-top:var(--space-2, 0.5rem)">
283
- <span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
284
- ${isBlocked ? `<span class="badge badge-danger">${icon('alertTriangle', 10)} Blocked</span>` : ''}
285
- </div>
281
+ </div>
282
+ <div class="scrum-card-meta" style="display:flex;flex-wrap:wrap;gap:var(--space-1, 0.25rem);margin-top:var(--space-2, 0.5rem)">
283
+ <span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
284
+ ${task.executionOwner ? `<span class="badge badge-muted">${esc(`exec:${task.executionOwner}`)}</span>` : ''}
285
+ ${task.sourceId ? `<span class="badge badge-muted">${esc(`plan:${task.sourceId}`)}</span>` : ''}
286
+ ${task.rootId && task.rootId !== task.id ? `<span class="badge badge-muted">${esc(`root:${task.rootTitle}`)}</span>` : ''}
287
+ ${task.awaitingUserConfirmation ? `<span class="badge badge-warning">${t('ui.tasks.awaitingUser', {}, 'Awaiting user')}</span>` : ''}
288
+ ${task.verificationPending ? `<span class="badge badge-accent">${t('ui.tasks.verifyPending', {}, 'Verify state')}</span>` : ''}
289
+ ${isBlocked ? `<span class="badge badge-danger">${icon('alertTriangle', 10)} Blocked</span>` : ''}
290
+ </div>
286
291
  </div>
287
292
  `;
288
293
  }
@@ -309,10 +314,13 @@ function _renderBacklog(pendingTasks, phases) {
309
314
  <div class="scrum-backlog-item" data-task-id="${esc(task.id)}" draggable="true"
310
315
  style="display:flex;align-items:center;gap:var(--space-3, 0.75rem);padding:var(--space-2, 0.5rem) var(--space-3, 0.75rem);border-radius:var(--radius-sm, 4px);border:1px solid var(--border, rgba(255,255,255,0.08));cursor:grab"
311
316
  role="listitem" tabindex="0">
312
- <span style="flex:1;font-size:var(--text-sm, 0.875rem);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(task.title)}</span>
313
- <span class="badge badge-${priorityVariant[task.priority] || 'muted'}" style="flex-shrink:0">${esc(task.priority)}</span>
314
- ${phaseInfo ? `<span class="badge badge-muted" style="flex-shrink:0">${esc(phaseInfo.label)}</span>` : ''}
315
- </div>
317
+ <span style="flex:1;font-size:var(--text-sm, 0.875rem);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(task.title)}</span>
318
+ <span class="badge badge-${priorityVariant[task.priority] || 'muted'}" style="flex-shrink:0">${esc(task.priority)}</span>
319
+ ${phaseInfo ? `<span class="badge badge-muted" style="flex-shrink:0">${esc(phaseInfo.label)}</span>` : ''}
320
+ ${task.executionOwner ? `<span class="badge badge-muted">${esc(`exec:${task.executionOwner}`)}</span>` : ''}
321
+ ${task.sourceId ? `<span class="badge badge-muted">${esc(`plan:${task.sourceId}`)}</span>` : ''}
322
+ ${task.rootId && task.rootId !== task.id ? `<span class="badge badge-muted">${esc(`root:${task.rootTitle}`)}</span>` : ''}
323
+ </div>
316
324
  `;
317
325
  }).join('');
318
326
 
@@ -460,10 +468,14 @@ function _bindDragDrop(board, columnSelector) {
460
468
  const targetStatuses = SCRUM_COLUMNS.find(c => c.id === scrumStatus)?.statuses || [];
461
469
  if (targetStatuses.includes(task.status)) return; // already there
462
470
 
463
- try {
464
- await api.taskAction(taskId, action,
465
- t('ui.scrum.movedFromBoard', { status: scrumStatus }, `Moved to ${scrumStatus} from sprint board.`)
466
- );
471
+ try {
472
+ await api.taskAction(taskId, action,
473
+ t('ui.scrum.movedFromBoard', { status: scrumStatus }, `Moved to ${scrumStatus} from sprint board.`),
474
+ {
475
+ actor: 'user',
476
+ source: 'scrum',
477
+ }
478
+ );
467
479
  flash(
468
480
  t('ui.scrum.movedSuccess', { status: scrumStatus }, `Task moved to ${scrumStatus}.`),
469
481
  'success'
@@ -11,14 +11,15 @@ import * as consoleLogger from '../console-logger.js';
11
11
  import * as onboarding from '../onboarding.js';
12
12
  import { t } from '../i18n.js';
13
13
 
14
- const NAV_ITEMS = [
15
- { id: 'projects', label: 'Projects', icon: 'folder' },
16
- { id: 'dashboard', label: 'Dashboard', icon: 'dashboard' },
17
- { id: 'tasks', label: 'Tasks', icon: 'tasks', badge: true },
18
- { id: 'timeline', label: 'Timeline', icon: 'timeline' },
19
- { id: 'terminal', label: 'Terminal', icon: 'terminal2' },
20
- { id: 'settings', label: 'Settings', icon: 'settings' },
21
- ];
14
+ const NAV_ITEMS = [
15
+ { id: 'projects', label: 'Projects', icon: 'folder' },
16
+ { id: 'dashboard', label: 'Dashboard', icon: 'dashboard' },
17
+ { id: 'plans', label: 'Plans', icon: 'fileText' },
18
+ { id: 'tasks', label: 'Tasks', icon: 'tasks', badge: true },
19
+ { id: 'timeline', label: 'Timeline', icon: 'timeline' },
20
+ { id: 'terminal', label: 'Terminal', icon: 'terminal2' },
21
+ { id: 'settings', label: 'Settings', icon: 'settings' },
22
+ ];
22
23
 
23
24
  const FOOTER_ITEMS = [
24
25
  { id: 'help', label: 'Help & Tour', icon: 'help', action: 'tour' },
@@ -19,7 +19,12 @@ import * as timeTracker from '../time-tracker.js';
19
19
 
20
20
  /* ─────────────────────────── Mode state ─────────────────────────── */
21
21
 
22
- let _activeMode = localStorage.getItem('ops-tasks-mode') || 'kanban';
22
+ let _activeMode = localStorage.getItem('ops-tasks-mode') || 'kanban';
23
+ let _collapsedNodes = new Set(JSON.parse(sessionStorage.getItem('ops-task-collapsed') || '[]'));
24
+
25
+ function _persistCollapsed() {
26
+ sessionStorage.setItem('ops-task-collapsed', JSON.stringify([..._collapsedNodes]));
27
+ }
23
28
 
24
29
  /* ─────────────────────────── Public API ─────────────────────────── */
25
30
 
@@ -28,13 +33,15 @@ export async function render() {
28
33
  if (!payload) return `<div class="empty-state" style="margin:3rem">${t('ui.tasks.noData', {}, 'No project data.')}</div>`;
29
34
 
30
35
  const projectName = payload.control?.project?.name || payload.control?.name || '';
31
- const activeFilters = filters.load('tasks');
32
- const allTasks = payload.derived?.tasks || [];
33
- const filteredTasks = filters.apply(allTasks, activeFilters);
34
- const phases = state.getPhases();
35
- const streams = [...new Set(allTasks.map(t => t.stream).filter(Boolean))];
36
-
37
- const modeContent = _renderModeContent(filteredTasks, phases, payload);
36
+ const activeFilters = filters.load('tasks');
37
+ const allTasks = payload.derived?.tasks || [];
38
+ const filteredTasks = filters.apply(allTasks, activeFilters);
39
+ const phases = state.getPhases();
40
+ const streams = [...new Set(allTasks.map(t => t.stream).filter(Boolean))];
41
+ const sources = [...new Set(allTasks.map(t => t.sourceId || t.origin?.sourceId).filter(Boolean))];
42
+ const roots = allTasks.filter(t => t.rootId === t.id).map(t => ({ id: t.id, title: t.title }));
43
+
44
+ const modeContent = _renderModeContent(filteredTasks, phases, payload);
38
45
 
39
46
  return `
40
47
  <section class="view-section view-enter">
@@ -53,11 +60,11 @@ export async function render() {
53
60
  ${icon('plus', 14)} ${t('ui.tasks.new', {}, 'New task')}
54
61
  </button>
55
62
  </div>
56
- </div>
57
-
58
- <div id="tasks-filter-bar">
59
- ${filters.renderBar('tasks', activeFilters, { phases, streams })}
60
- </div>
63
+ </div>
64
+
65
+ <div id="tasks-filter-bar">
66
+ ${filters.renderBar('tasks', activeFilters, { phases, streams, sources, roots })}
67
+ </div>
61
68
 
62
69
  <div id="tasks-mode-content">
63
70
  ${modeContent}
@@ -128,12 +135,21 @@ function _refreshModeContent() {
128
135
  const payload = state.getPayload();
129
136
  if (!payload) return;
130
137
 
131
- const activeFilters = filters.load('tasks');
132
- const allTasks = payload.derived?.tasks || [];
133
- const filteredTasks = filters.apply(allTasks, activeFilters);
134
- const phases = state.getPhases();
135
-
136
- const container = document.getElementById('tasks-mode-content');
138
+ const activeFilters = filters.load('tasks');
139
+ const allTasks = payload.derived?.tasks || [];
140
+ const filteredTasks = filters.apply(allTasks, activeFilters);
141
+ const phases = state.getPhases();
142
+ const streams = [...new Set(allTasks.map(t => t.stream).filter(Boolean))];
143
+ const sources = [...new Set(allTasks.map(t => t.sourceId || t.origin?.sourceId).filter(Boolean))];
144
+ const roots = allTasks.filter(t => t.rootId === t.id).map(t => ({ id: t.id, title: t.title }));
145
+
146
+ const filterBar = document.getElementById('tasks-filter-bar');
147
+ if (filterBar) {
148
+ filterBar.innerHTML = filters.renderBar('tasks', activeFilters, { phases, streams, sources, roots });
149
+ filters.bindBar('tasks', () => _refreshModeContent());
150
+ }
151
+
152
+ const container = document.getElementById('tasks-mode-content');
137
153
  if (container) {
138
154
  container.innerHTML = _renderModeContent(filteredTasks, phases, payload);
139
155
  }
@@ -212,39 +228,70 @@ function _renderStatusFilters(statusLabels, totals) {
212
228
 
213
229
  /* ─── Task list cards ─── */
214
230
 
215
- function _renderTaskList(tasks, statusLabels, phases) {
216
- if (!tasks.length) return `<div class="empty-state">${t('ui.tasks.noMatch', {}, 'No matching tasks.')}</div>`;
217
- const selectedId = state.get('selectedTaskId');
218
- const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
219
-
220
- return tasks.map(tk => {
221
- const phase = phases.find(p => p.id === tk.phase);
222
- return `
223
- <div class="task-card ${tk.id === selectedId ? 'is-selected' : ''}"
224
- data-task-id="${esc(tk.id)}"
225
- role="listitem" tabindex="0"
226
- aria-selected="${tk.id === selectedId}"
227
- aria-label="${esc(tk.title)}"
228
- >
229
- <strong class="task-card-title">${esc(tk.title)}</strong>
230
- <span class="task-card-id">${esc(tk.id)}</span>
231
- <div class="task-card-meta" style="margin-top:var(--space-2)">
232
- <span class="badge badge-${priorityVariant[tk.priority] || 'muted'}">${esc(tk.priority)}</span>
233
- <span class="badge status-${tk.status}">${esc(statusLabels[tk.status] || tk.status)}</span>
234
- ${phase ? `<span class="badge badge-muted">${esc(phase.label)}</span>` : ''}
235
- </div>
236
- </div>
237
- `;
238
- }).join('');
239
- }
231
+ function _renderTaskList(tasks, statusLabels, phases) {
232
+ if (!tasks.length) return `<div class="empty-state">${t('ui.tasks.noMatch', {}, 'No matching tasks.')}</div>`;
233
+ const selectedId = state.get('selectedTaskId');
234
+ const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
235
+ const visibleTasks = tasks.filter(tk => {
236
+ let currentParentId = tk.parentId;
237
+ while (currentParentId) {
238
+ if (_collapsedNodes.has(currentParentId)) return false;
239
+ const parent = tasks.find(entry => entry.id === currentParentId);
240
+ currentParentId = parent?.parentId || null;
241
+ }
242
+ return true;
243
+ });
244
+
245
+ return visibleTasks.map(tk => {
246
+ const phase = phases.find(p => p.id === tk.phase);
247
+ const isCollapsed = _collapsedNodes.has(tk.id);
248
+ const indent = (tk.depth || 0) * 18;
249
+ return `
250
+ <div class="task-card ${tk.id === selectedId ? 'is-selected' : ''}"
251
+ data-task-id="${esc(tk.id)}"
252
+ role="listitem" tabindex="0"
253
+ aria-selected="${tk.id === selectedId}"
254
+ aria-label="${esc(tk.title)}"
255
+ style="padding-left:calc(var(--space-4) + ${indent}px)"
256
+ >
257
+ <div style="display:flex;align-items:flex-start;gap:var(--space-2)">
258
+ ${tk.isParent ? `
259
+ <button class="btn btn-ghost btn-sm" type="button" data-task-toggle="${esc(tk.id)}" aria-label="${isCollapsed ? 'Expand children' : 'Collapse children'}" style="padding:0;min-width:auto">
260
+ ${icon(isCollapsed ? 'arrowRight' : 'chevronDown', 14)}
261
+ </button>
262
+ ` : `<span style="width:14px;display:inline-block"></span>`}
263
+ <strong class="task-card-title">${esc(tk.title)}</strong>
264
+ </div>
265
+ <span class="task-card-id">${esc(tk.id)}</span>
266
+ <div class="task-card-meta" style="margin-top:var(--space-2)">
267
+ <span class="badge badge-${priorityVariant[tk.priority] || 'muted'}">${esc(tk.priority)}</span>
268
+ <span class="badge status-${tk.status}">${esc(statusLabels[tk.status] || tk.status)}</span>
269
+ ${phase ? `<span class="badge badge-muted">${esc(phase.label)}</span>` : ''}
270
+ ${tk.executionOwner ? `<span class="badge badge-muted">${esc(`exec:${tk.executionOwner}`)}</span>` : ''}
271
+ ${tk.sourceId ? `<span class="badge badge-muted">${esc(`plan:${tk.sourceId}`)}</span>` : ''}
272
+ ${tk.rootId && tk.rootId !== tk.id ? `<span class="badge badge-muted">${esc(`root:${tk.rootTitle}`)}</span>` : ''}
273
+ ${tk.awaitingUserConfirmation ? `<span class="badge badge-warning">${t('ui.tasks.awaitingUser', {}, 'Awaiting user')}</span>` : ''}
274
+ ${tk.verificationPending ? `<span class="badge badge-accent">${t('ui.tasks.verifyPending', {}, 'Verify state')}</span>` : ''}
275
+ ${tk.detached ? `<span class="badge badge-danger">${t('ui.tasks.detached', {}, 'Detached')}</span>` : ''}
276
+ ${tk.isParent ? `<span class="badge badge-muted">${esc(`${tk.childrenCount} child${tk.childrenCount === 1 ? '' : 'ren'}`)}</span>` : ''}
277
+ </div>
278
+ </div>
279
+ `;
280
+ }).join('');
281
+ }
240
282
 
241
283
  /* ─── Task editor ─── */
242
284
 
243
- function _renderEditor(task, phases) {
244
- const isNew = !task;
245
- const phasesOpts = phases.map(p =>
246
- `<option value="${esc(p.id)}" ${!isNew && task.phase === p.id ? 'selected' : ''}>${esc(p.id)} — ${esc(p.label)}</option>`
247
- ).join('');
285
+ function _renderEditor(task, phases) {
286
+ const isNew = !task;
287
+ const allTasks = state.getPayload()?.derived?.tasks || [];
288
+ const parentOptions = allTasks
289
+ .filter(candidate => !task || candidate.id !== task.id)
290
+ .map(candidate => `<option value="${esc(candidate.id)}" ${!isNew && task.parentId === candidate.id ? 'selected' : ''}>${esc(candidate.title)} (${esc(candidate.id)})</option>`)
291
+ .join('');
292
+ const phasesOpts = phases.map(p =>
293
+ `<option value="${esc(p.id)}" ${!isNew && task.phase === p.id ? 'selected' : ''}>${esc(p.id)} — ${esc(p.label)}</option>`
294
+ ).join('');
248
295
 
249
296
  const statuses = [
250
297
  { id: 'pending', label: t('status.pending', {}, 'Pending') },
@@ -260,32 +307,32 @@ function _renderEditor(task, phases) {
260
307
  <div class="panel-header-left">
261
308
  <p class="eyebrow">Task Studio</p>
262
309
  <h3 class="panel-title" id="editor-title">${isNew ? t('ui.tasks.new', {}, 'New task') : esc(task.title)}</h3>
263
- </div>
264
- <div class="panel-header-right">
265
- ${!isNew ? `
266
- <button class="btn btn-ghost btn-sm" id="timer-quick-btn" type="button" title="${t('ui.tasks.timerTitle', {}, 'Start timer for this task')}">
267
- ${icon('timer', 14)} Timer
268
- </button>
269
- <button class="btn btn-ghost btn-sm" id="duplicate-btn" type="button" aria-label="${t('ui.tasks.duplicate', {}, 'Duplicate task')}">
270
- ${icon('copy', 14)}
271
- </button>
310
+ </div>
311
+ <div class="panel-header-right">
312
+ ${!isNew ? `
313
+ <button class="btn btn-ghost btn-sm" id="timer-quick-btn" type="button" ${task.isParent ? 'disabled' : ''} title="${task.isParent ? t('ui.tasks.parentTimerHint', {}, 'Track time on leaf tasks only.') : t('ui.tasks.timerTitle', {}, 'Start timer for this task')}">
314
+ ${icon('timer', 14)} Timer
315
+ </button>
316
+ <button class="btn btn-ghost btn-sm" id="duplicate-btn" type="button" aria-label="${t('ui.tasks.duplicate', {}, 'Duplicate task')}">
317
+ ${icon('copy', 14)}
318
+ </button>
272
319
  ` : ''}
273
320
  <button class="btn btn-ghost btn-sm" id="clear-task-btn" type="button" aria-label="${t('ui.tasks.clear', {}, 'Clear form')}">
274
321
  ${icon('x', 14)}
275
322
  </button>
276
323
  </div>
277
324
  </div>
278
-
279
- <!-- Quick action strip -->
280
- ${!isNew ? `
281
- <div class="panel-footer" style="display:flex;gap:var(--space-2);flex-wrap:wrap" role="group" aria-label="${t('ui.tasks.quickActions', {}, 'Quick task actions')}">
282
- <button class="chip is-active" type="button" data-task-action="start" aria-label="${t('ui.tasks.start', {}, 'Start task')}">${t('ui.tasks.startLabel', {}, 'Start')}</button>
283
- <button class="chip" type="button" data-task-action="review" aria-label="${t('ui.tasks.review', {}, 'Send to review')}">${t('ui.tasks.reviewLabel', {}, 'Review')}</button>
284
- <button class="chip" type="button" data-task-action="complete" aria-label="${t('ui.tasks.complete', {}, 'Complete task')}">${t('ui.tasks.completeLabel', {}, 'Complete')}</button>
285
- <button class="chip" type="button" data-task-action="block" aria-label="${t('ui.tasks.block', {}, 'Block task')}">${t('ui.tasks.blockLabel', {}, 'Block')}</button>
286
- <button class="chip" type="button" data-task-action="pending" aria-label="${t('ui.tasks.pending', {}, 'Return to pending')}">${t('status.pending', {}, 'Pending')}</button>
287
- </div>
288
- ` : ''}
325
+
326
+ <!-- Quick action strip -->
327
+ ${!isNew ? `
328
+ <div class="panel-footer" style="display:flex;gap:var(--space-2);flex-wrap:wrap" role="group" aria-label="${t('ui.tasks.quickActions', {}, 'Quick task actions')}">
329
+ <button class="chip is-active" type="button" data-task-action="start" ${task.isParent ? 'disabled' : ''} aria-label="${t('ui.tasks.start', {}, 'Start task')}">${t('ui.tasks.startLabel', {}, 'Start')}</button>
330
+ <button class="chip" type="button" data-task-action="review" ${task.isParent ? 'disabled' : ''} aria-label="${t('ui.tasks.review', {}, 'Send to review')}">${t('ui.tasks.reviewLabel', {}, 'Review')}</button>
331
+ <button class="chip" type="button" data-task-action="complete" ${task.isParent ? 'disabled' : ''} aria-label="${t('ui.tasks.complete', {}, 'Complete task')}">${t('ui.tasks.completeLabel', {}, 'Complete')}</button>
332
+ <button class="chip" type="button" data-task-action="block" ${task.isParent ? 'disabled' : ''} aria-label="${t('ui.tasks.block', {}, 'Block task')}">${t('ui.tasks.blockLabel', {}, 'Block')}</button>
333
+ <button class="chip" type="button" data-task-action="pending" ${task.isParent ? 'disabled' : ''} aria-label="${t('ui.tasks.pending', {}, 'Return to pending')}">${t('status.pending', {}, 'Pending')}</button>
334
+ </div>
335
+ ` : ''}
289
336
 
290
337
  <div class="panel-body">
291
338
  <form id="task-form" class="stack stack-md" novalidate>
@@ -312,29 +359,55 @@ function _renderEditor(task, phases) {
312
359
  </div>
313
360
 
314
361
  <div class="field-row">
315
- <div class="field">
316
- <label for="task-status">${t('ui.tasks.field.status', {}, 'Status')}</label>
317
- <select id="task-status" name="status">
318
- ${statuses.map(s => `<option value="${s.id}" ${!isNew && task.status === s.id ? 'selected' : ''}>${s.label}</option>`).join('')}
319
- </select>
320
- </div>
321
- <div class="field">
322
- <label for="task-stream">${t('ui.tasks.field.stream', {}, 'Stream')}</label>
323
- <input id="task-stream" name="stream" type="text"
324
- value="${isNew ? 'Operations' : esc(task.stream || '')}"
362
+ <div class="field">
363
+ <label for="task-status">${t('ui.tasks.field.status', {}, 'Status')}</label>
364
+ <select id="task-status" name="status" ${!isNew && task.isParent ? 'disabled' : ''}>
365
+ ${statuses.map(s => `<option value="${s.id}" ${!isNew && task.status === s.id ? 'selected' : ''}>${s.label}</option>`).join('')}
366
+ </select>
367
+ ${!isNew && task.isParent ? `<p style="font-size:var(--text-xs);color:var(--text-secondary);margin-top:var(--space-1)">${t('ui.tasks.parentStatusHint', {}, 'Parent status is rolled up from its leaf tasks.')}</p>` : ''}
368
+ </div>
369
+ <div class="field">
370
+ <label for="task-stream">${t('ui.tasks.field.stream', {}, 'Stream')}</label>
371
+ <input id="task-stream" name="stream" type="text"
372
+ value="${isNew ? 'Operations' : esc(task.stream || '')}"
325
373
  placeholder="Operations" />
326
374
  </div>
327
375
  </div>
328
376
 
329
- <div class="checkbox-field">
330
- <input id="task-required" type="checkbox" name="required" ${isNew || task.required !== false ? 'checked' : ''} />
331
- <label for="task-required">${t('ui.tasks.field.required', {}, 'Required for delivery')}</label>
332
- </div>
333
-
334
- <div class="field">
335
- <label for="task-summary">${t('ui.tasks.field.summary', {}, 'Summary')}</label>
336
- <textarea id="task-summary" name="summary" rows="3"
337
- placeholder="${t('ui.tasks.placeholder.summary', {}, 'Short description of the task')}">${isNew ? '' : esc(task.summary || '')}</textarea>
377
+ <div class="checkbox-field">
378
+ <input id="task-required" type="checkbox" name="required" ${isNew || task.required !== false ? 'checked' : ''} />
379
+ <label for="task-required">${t('ui.tasks.field.required', {}, 'Required for delivery')}</label>
380
+ </div>
381
+
382
+ <div class="field">
383
+ <label for="task-execution-owner">${t('ui.tasks.field.executionOwner', {}, 'Execution owner')}</label>
384
+ <select id="task-execution-owner" name="executionOwner">
385
+ ${[
386
+ { id: 'shared', label: t('ui.tasks.execution.shared', {}, 'Shared') },
387
+ { id: 'agent', label: t('ui.tasks.execution.agent', {}, 'Agent') },
388
+ { id: 'user', label: t('ui.tasks.execution.user', {}, 'User') },
389
+ ].map(option => `<option value="${option.id}" ${(!isNew ? (task.executionOwner || task.execution?.owner || 'shared') : 'shared') === option.id ? 'selected' : ''}>${esc(option.label)}</option>`).join('')}
390
+ </select>
391
+ </div>
392
+
393
+ <div class="field-row">
394
+ <div class="field">
395
+ <label for="task-parent">${t('ui.tasks.field.parent', {}, 'Parent')}</label>
396
+ <select id="task-parent" name="parentId">
397
+ <option value="">${t('ui.tasks.none', {}, 'None')}</option>
398
+ ${parentOptions}
399
+ </select>
400
+ </div>
401
+ <div class="field">
402
+ <label for="task-sequence">${t('ui.tasks.field.sequence', {}, 'Sequence')}</label>
403
+ <input id="task-sequence" name="sequence" type="number" step="10" value="${isNew ? '' : esc(task.sequence ?? '')}" placeholder="10" />
404
+ </div>
405
+ </div>
406
+
407
+ <div class="field">
408
+ <label for="task-summary">${t('ui.tasks.field.summary', {}, 'Summary')}</label>
409
+ <textarea id="task-summary" name="summary" rows="3"
410
+ placeholder="${t('ui.tasks.placeholder.summary', {}, 'Short description of the task')}">${isNew ? '' : esc(task.summary || '')}</textarea>
338
411
  </div>
339
412
 
340
413
  <div class="field">
@@ -361,16 +434,30 @@ function _renderEditor(task, phases) {
361
434
  placeholder="${t('ui.tasks.placeholder.note', {}, 'Optional note to append to history')}"></textarea>
362
435
  </div>
363
436
 
364
- <div class="form-actions">
365
- <button class="btn btn-primary" type="submit" id="save-task-btn">
366
- ${icon('check', 16)} ${isNew ? t('ui.tasks.create', {}, 'Create task') : t('ui.tasks.save', {}, 'Save changes')}
367
- </button>
368
- </div>
369
-
370
- </form>
371
-
372
- ${!isNew && task.history?.length ? `
373
- <div style="margin-top:var(--space-6)">
437
+ <div class="form-actions">
438
+ <button class="btn btn-primary" type="submit" id="save-task-btn">
439
+ ${icon('check', 16)} ${isNew ? t('ui.tasks.create', {}, 'Create task') : t('ui.tasks.save', {}, 'Save changes')}
440
+ </button>
441
+ </div>
442
+
443
+ </form>
444
+
445
+ ${!isNew ? `
446
+ <div class="panel" style="margin-top:var(--space-4)">
447
+ <p class="eyebrow">${t('ui.tasks.traceability', {}, 'Traceability')}</p>
448
+ <div class="stack stack-sm">
449
+ <div class="info-row"><p class="label-sm">Origin</p><p class="value">${esc(task.origin?.kind || 'manual')}</p></div>
450
+ <div class="info-row"><p class="label-sm">Source</p><p class="value">${esc(task.sourceId || task.origin?.sourceId || 'manual')}</p></div>
451
+ <div class="info-row"><p class="label-sm">Detached</p><p class="value">${task.detached ? 'yes' : 'no'}</p></div>
452
+ <div class="info-row"><p class="label-sm">Execution</p><p class="value">${esc(task.executionOwner || task.execution?.owner || 'shared')}</p></div>
453
+ <div class="info-row"><p class="label-sm">Awaiting user</p><p class="value">${task.awaitingUserConfirmation ? 'yes' : 'no'}</p></div>
454
+ <div class="info-row"><p class="label-sm">Verify state</p><p class="value">${task.verificationPending ? 'yes' : 'no'}</p></div>
455
+ </div>
456
+ </div>
457
+ ` : ''}
458
+
459
+ ${!isNew && task.history?.length ? `
460
+ <div style="margin-top:var(--space-6)">
374
461
  <p class="eyebrow" style="margin-bottom:var(--space-3)">${t('ui.tasks.history', {}, 'History')}</p>
375
462
  <div class="stack stack-sm">
376
463
  ${task.history.slice(-5).reverse().map(h => `
@@ -388,11 +475,22 @@ function _renderEditor(task, phases) {
388
475
 
389
476
  /* ─── List mode event binding ─── */
390
477
 
391
- function _bindListEvents() {
392
- // Select task from list
393
- document.getElementById('task-list')?.addEventListener('click', e => {
394
- const card = e.target.closest('[data-task-id]');
395
- if (!card) return;
478
+ function _bindListEvents() {
479
+ document.querySelectorAll('[data-task-toggle]').forEach(button => {
480
+ button.addEventListener('click', event => {
481
+ event.stopPropagation();
482
+ const taskId = button.dataset.taskToggle;
483
+ if (_collapsedNodes.has(taskId)) _collapsedNodes.delete(taskId);
484
+ else _collapsedNodes.add(taskId);
485
+ _persistCollapsed();
486
+ _refreshModeContent();
487
+ });
488
+ });
489
+
490
+ // Select task from list
491
+ document.getElementById('task-list')?.addEventListener('click', e => {
492
+ const card = e.target.closest('[data-task-id]');
493
+ if (!card) return;
396
494
  const id = card.dataset.taskId;
397
495
  state.update('selectedTaskId', id);
398
496
  const editor = document.getElementById('task-editor');
@@ -455,17 +553,20 @@ function _bindEditorForm() {
455
553
  // Quick action strip
456
554
  document.querySelectorAll('[data-task-action]').forEach(btn => {
457
555
  btn.addEventListener('click', async () => {
458
- const taskId = state.get('selectedTaskId');
459
- if (!taskId) { flash(t('ui.tasks.selectFirst', {}, 'Select a task first.'), 'warning'); return; }
460
- const action = btn.dataset.taskAction;
461
- const note = document.getElementById('task-note')?.value?.trim() || '';
462
- try {
463
- await api.taskAction(taskId, action, note || t('ui.tasks.defaultActionNote', { action }, `Change to "${action}" from the board.`));
464
- flash(t('ui.tasks.updated', {}, 'Status updated.'), 'success');
465
- window.dispatchEvent(new CustomEvent('ops:refresh'));
466
- } catch (err) {
467
- flash(err.message, 'error');
468
- }
556
+ const taskId = state.get('selectedTaskId');
557
+ if (!taskId) { flash(t('ui.tasks.selectFirst', {}, 'Select a task first.'), 'warning'); return; }
558
+ const action = btn.dataset.taskAction;
559
+ const note = document.getElementById('task-note')?.value?.trim() || '';
560
+ try {
561
+ await api.taskAction(taskId, action, note || t('ui.tasks.defaultActionNote', { action }, `Change to "${action}" from the board.`), {
562
+ actor: 'user',
563
+ source: 'tasks_view',
564
+ });
565
+ flash(t('ui.tasks.updated', {}, 'Status updated.'), 'success');
566
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
567
+ } catch (err) {
568
+ flash(err.message, 'error');
569
+ }
469
570
  });
470
571
  });
471
572
 
@@ -486,18 +587,21 @@ function _bindEditorForm() {
486
587
  async function _submitForm() {
487
588
  const get = id => document.getElementById(id);
488
589
 
489
- const payload = {
490
- title: get('task-title')?.value.trim(),
491
- phase: get('task-phase')?.value,
492
- priority: get('task-priority')?.value,
493
- status: get('task-status')?.value,
494
- stream: get('task-stream')?.value.trim(),
495
- required: get('task-required')?.checked,
496
- summary: get('task-summary')?.value.trim(),
497
- acceptance: splitLines(get('task-acceptance')?.value || ''),
498
- dependsOn: splitLines(get('task-depends')?.value || ''),
499
- blocker: get('task-blocker')?.value.trim(),
500
- note: get('task-note')?.value.trim(),
590
+ const payload = {
591
+ title: get('task-title')?.value.trim(),
592
+ phase: get('task-phase')?.value,
593
+ priority: get('task-priority')?.value,
594
+ status: get('task-status')?.value,
595
+ stream: get('task-stream')?.value.trim(),
596
+ required: get('task-required')?.checked,
597
+ executionOwner: get('task-execution-owner')?.value || 'shared',
598
+ parentId: get('task-parent')?.value || '',
599
+ sequence: get('task-sequence')?.value || '',
600
+ summary: get('task-summary')?.value.trim(),
601
+ acceptance: splitLines(get('task-acceptance')?.value || ''),
602
+ dependsOn: splitLines(get('task-depends')?.value || ''),
603
+ blocker: get('task-blocker')?.value.trim(),
604
+ note: get('task-note')?.value.trim(),
501
605
  };
502
606
 
503
607
  if (!payload.title) {
@@ -508,14 +612,14 @@ async function _submitForm() {
508
612
 
509
613
  const selectedId = state.get('selectedTaskId');
510
614
  try {
511
- if (selectedId) {
512
- await api.updateTask(selectedId, payload);
513
- flash(t('ui.tasks.taskUpdated', {}, 'Task updated.'), 'success');
514
- } else {
515
- const result = await api.createTask(payload);
516
- state.update('selectedTaskId', result.task?.id);
517
- flash(t('ui.tasks.taskCreated', {}, 'Task created.'), 'success');
518
- }
615
+ if (selectedId) {
616
+ await api.updateTask(selectedId, payload, { actor: 'user', source: 'tasks_editor' });
617
+ flash(t('ui.tasks.taskUpdated', {}, 'Task updated.'), 'success');
618
+ } else {
619
+ const result = await api.createTask({ ...payload, actor: 'user', source: 'tasks_editor' });
620
+ state.update('selectedTaskId', result.task?.id);
621
+ flash(t('ui.tasks.taskCreated', {}, 'Task created.'), 'success');
622
+ }
519
623
  window.dispatchEvent(new CustomEvent('ops:refresh'));
520
624
  } catch (err) {
521
625
  flash(err.message, 'error');