feat(board): master-task board, non-destructive stall watchdog, review flow
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Has been skipped

Board is now a clean master-task view:
- GetBoardAsync returns only top-level (master) tasks; child-tasks render
  nested inside their parent card instead of as separate column cards, so a
  big task split into many sub-tasks stays one card (orphans treated as master)
- New DoneChildTaskCount on the DTO for real progress bars
- Child/detail consumers (GetChildren endpoint, TaskBridgeService) query
  children directly instead of scraping the flat board

Stall watchdog (replaces destructive auto-reset):
- StaleTaskRecoveryService.FlagStalledInProgressTasksAsync marks In-progress
  tasks with no activity past the threshold as stalled (activity event +
  Iris notification) WITHOUT resetting the column — no work is discarded.
  Idempotent: a task is not re-flagged until real progress happens
- BackgroundService now runs this watchdog (TaskRecovery:StalledMinutes=40,
  interval 10m); hard reset kept only on the explicit manual endpoint

Review flow (Bao/Iris only):
- POST tasks/{id}/approve (Review -> Done)
- POST tasks/{id}/request-changes (Review -> target, mandatory comment,
  ExpectedFrom=iris, notifies Iris)

Frontend:
- BoardCard component: master card with ball chip (who has it), progress from
  children, expand to show children grouped by agent with per-child state +
  stalled marker, stalled chip on the master, review action buttons
- Request-changes modal; tasks store approveReview/requestChanges actions

Tests: watchdog flag/idempotency + review threshold; 135 backend tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 22:26:50 +02:00
parent f564ecfbc7
commit aef76d5f45
15 changed files with 789 additions and 432 deletions
+115 -277
View File
@@ -13,12 +13,17 @@
* - Waiting section for Iris overview
*/
import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText } from '@lucide/vue'
import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText, RotateCcw } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useTaskStore } from '../stores/tasks'
import { useTaskStore, type DashboardTaskDto } from '../stores/tasks'
import { useLiveSyncStore } from '../stores/liveSync'
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
import BoardCard from '../components/board/BoardCard.vue'
/** Schwelle (min) ohne Aktivität, ab der ein In-Bearbeitung-Task als „hängt" gilt.
* Spiegelt die Backend-Watchdog-Schwelle (TaskRecovery:StalledMinutes). */
const STALL_THRESHOLD_MIN = 40
type BoardTask = ReturnType<typeof flattenBoard>[number]
@@ -95,6 +100,46 @@ async function handleCreateTask() {
}
}
/* ── Review-Aktionen (Bao/Iris) ───────────────────── */
const reviewError = ref('')
const showChangesModal = ref(false)
const changesTask = ref<DashboardTaskDto | null>(null)
const changesComment = ref('')
const changesTarget = ref('In progress')
const changesSubmitting = ref(false)
async function handleApprove(id: string) {
reviewError.value = ''
try {
await taskStore.approveReview(id)
} catch (err) {
reviewError.value = err instanceof Error ? err.message : 'Abnahme fehlgeschlagen'
}
}
function openRequestChanges(task: DashboardTaskDto) {
changesTask.value = task
changesComment.value = ''
changesTarget.value = 'In progress'
reviewError.value = ''
showChangesModal.value = true
}
async function submitRequestChanges() {
if (!changesTask.value || !changesComment.value.trim()) return
changesSubmitting.value = true
reviewError.value = ''
try {
await taskStore.requestChanges(changesTask.value.id, changesComment.value.trim(), changesTarget.value)
showChangesModal.value = false
changesTask.value = null
} catch (err) {
reviewError.value = err instanceof Error ? err.message : 'Konnte nicht zurückgegeben werden'
} finally {
changesSubmitting.value = false
}
}
/* ── Drag & Drop ────────────────────────────────── */
const draggedTaskId = ref<string | null>(null)
@@ -141,20 +186,6 @@ async function onDrop(e: DragEvent, targetState: string) {
}
/* ── Helpers ──────────────────────────────────────── */
function priorityLabel(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high') return 'High'
if (lower === 'low') return 'Low'
return 'Med'
}
function priorityColor(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high') return '#f87171'
if (lower === 'low') return '#60a5fa'
return '#facc15'
}
function statusTone(state: string): string {
switch (state.toLowerCase()) {
case 'done': return 'is-done'
@@ -202,6 +233,15 @@ const canSaveDetail = computed(() => detailForm.title.trim().length > 0 && !deta
*/
const canChangeState = computed(() => authStore.isIris || authStore.isBao)
/* Spalten-Konfiguration — Board zeigt nur Master-Tasks (Children nested in der Karte). */
const columns = computed(() => [
{ key: 'offen', name: 'Offen', tasks: taskStore.board.offen, dot: 'var(--st-queue)', ring: 'rgba(251,191,36,.25)' },
{ key: 'inProgress', name: 'In Bearbeitung', tasks: taskStore.board.inProgress, dot: 'var(--st-work)', ring: 'rgba(61,220,151,.25)' },
{ key: 'review', name: 'Review', tasks: taskStore.board.review, dot: '#fb923c', ring: 'rgba(251,146,60,.25)' },
{ key: 'done', name: 'Erledigt', tasks: taskStore.board.done, dot: 'var(--st-work)', ring: 'rgba(61,220,151,.25)' },
{ key: 'blocked', name: 'Blockiert', tasks: taskStore.board.blocked, dot: 'var(--st-block)', ring: 'rgba(251,113,133,.25)' },
])
function hydrateDetailForm(task: BoardTask | null) {
detailError.value = ''
detailSuccess.value = ''
@@ -283,18 +323,6 @@ function delegationBadge(task: BoardTask): string | null {
return null
}
function assigneeLabel(assignedTo: string | null | undefined): string {
return expectedFromLabel(assignedTo)
}
function assigneeClass(assignedTo: string | null | undefined): string {
if (!assignedTo) return ''
const lower = assignedTo.toLowerCase()
if (lower === 'iris') return 'assignee-iris'
if (lower === 'bao') return 'assignee-bao'
return 'assignee-agent'
}
/* ── Task Navigation ───────────────────────────── */
function navigateToTask(taskId: string) {
router.push('/tasks/' + taskId)
@@ -306,15 +334,6 @@ async function openQuickPeek(taskId: string) {
await loadDetailContext(taskId)
}
function handleCardClick(event: MouseEvent, taskId: string) {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
openQuickPeek(taskId)
} else {
navigateToTask(taskId)
}
}
function closeDetailPanel() {
showDetailPanel.value = false
selectedTaskId.value = null
@@ -527,254 +546,34 @@ onUnmounted(() => {
<div v-else class="board-columns">
<div
v-for="col in columns"
:key="col.key"
class="col"
:class="{ 'drag-over': dragOverColumn === 'offen' }"
@dragover="onDragOver($event, 'offen')"
:class="{ 'drag-over': dragOverColumn === col.key, 'col-blocked': col.key === 'blocked' }"
@dragover="onDragOver($event, col.key)"
@dragleave="onDragLeave"
@drop="onDrop($event, 'offen')"
@drop="onDrop($event, col.key)"
>
<div class="col-header">
<span class="col-icon" style="background: var(--st-queue); box-shadow: 0 0 0 2px rgba(251,191,36,.25);"></span>
<span class="col-name">Offen</span>
<span class="col-count">{{ taskStore.board.offen.length }}</span>
<span class="col-icon" :style="{ background: col.dot, boxShadow: `0 0 0 2px ${col.ring}` }"></span>
<span class="col-name">{{ col.name }}</span>
<span class="col-count">{{ col.tasks.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.offen"
<BoardCard
v-for="task in col.tasks"
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
:task="task"
:column="col.key"
:can-review="canChangeState"
:stall-threshold-min="STALL_THRESHOLD_MIN"
@open="openQuickPeek"
@approve="handleApprove"
@request-changes="openRequestChanges"
@dragstart="onDragStart"
@dragend="onDragEnd"
>
<div class="card-top">
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
{{ delegationBadge(task) }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
:class="assigneeClass(task.assignedTo)"
>
{{ assigneeLabel(task.assignedTo) }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.offen.length" class="empty-col">Keine Aufgaben</div>
</div>
</div>
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'inProgress' }"
@dragover="onDragOver($event, 'inProgress')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'inProgress')"
>
<div class="col-header">
<span class="col-icon" style="background: var(--st-work); box-shadow: 0 0 0 2px rgba(61,220,151,.25);"></span>
<span class="col-name">In Bearbeitung</span>
<span class="col-count">{{ taskStore.board.inProgress.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.inProgress"
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
{{ delegationBadge(task) }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
:class="assigneeClass(task.assignedTo)"
>
{{ assigneeLabel(task.assignedTo) }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.inProgress.length" class="empty-col">Keine Aufgaben</div>
</div>
</div>
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'review' }"
@dragover="onDragOver($event, 'review')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'review')"
>
<div class="col-header">
<span class="col-icon" style="background: #fb923c; box-shadow: 0 0 0 2px rgba(251,146,60,.25);"></span>
<span class="col-name">Review</span>
<span class="col-count">{{ taskStore.board.review.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.review"
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
{{ delegationBadge(task) }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
:class="assigneeClass(task.assignedTo)"
>
{{ assigneeLabel(task.assignedTo) }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.review.length" class="empty-col">Keine Aufgaben</div>
</div>
</div>
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'done' }"
@dragover="onDragOver($event, 'done')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'done')"
>
<div class="col-header">
<span class="col-icon" style="background: var(--st-work); box-shadow: 0 0 0 2px rgba(61,220,151,.25);"></span>
<span class="col-name">Erledigt</span>
<span class="col-count">{{ taskStore.board.done.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.done"
:key="task.id"
type="button"
class="card"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
:class="assigneeClass(task.assignedTo)"
>
{{ assigneeLabel(task.assignedTo) }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.done.length" class="empty-col">Keine Aufgaben</div>
</div>
</div>
<div
class="col col-blocked"
:class="{ 'drag-over': dragOverColumn === 'blocked' }"
@dragover="onDragOver($event, 'blocked')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'blocked')"
>
<div class="col-header">
<span class="col-icon" style="background: var(--st-block); box-shadow: 0 0 0 2px rgba(251,113,133,.25);"></span>
<span class="col-name">Blockiert</span>
<span class="col-count">{{ taskStore.board.blocked.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.blocked"
:key="task.id"
type="button"
class="card card-blocked"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
{{ delegationBadge(task) }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
:class="assigneeClass(task.assignedTo)"
>
{{ assigneeLabel(task.assignedTo) }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.blocked.length" class="empty-col">Keine Blockierer</div>
/>
<div v-if="!col.tasks.length" class="empty-col">Keine Aufgaben</div>
</div>
</div>
</div>
@@ -841,6 +640,45 @@ onUnmounted(() => {
</div>
</Teleport>
<Teleport to="body">
<div v-if="showChangesModal && changesTask" class="modal-overlay" @click.self="showChangesModal = false">
<div class="modal-card">
<div class="modal-header">
<h2><RotateCcw :size="16" /> Änderung anfordern</h2>
<button class="modal-close" @click="showChangesModal = false">&times;</button>
</div>
<form @submit.prevent="submitRequestChanges" class="modal-form">
<p class="changes-task-title">{{ changesTask.title }}</p>
<div class="field">
<label for="changes-comment">Was soll geändert werden? <span class="req">*</span></label>
<textarea
id="changes-comment"
v-model="changesComment"
class="field-input field-textarea"
placeholder="Konkretes Feedback für Iris — sie arbeitet autonom daran weiter…"
rows="4"
></textarea>
</div>
<div class="field">
<label for="changes-target">Zurück nach</label>
<select id="changes-target" v-model="changesTarget" class="field-input field-select">
<option value="In progress">In Bearbeitung</option>
<option value="Backlog">Offen</option>
<option value="Blocked">Blockiert</option>
</select>
</div>
<p v-if="reviewError" class="form-error">{{ reviewError }}</p>
<div class="modal-actions">
<button type="button" class="btn-cancel" @click="showChangesModal = false">Abbrechen</button>
<button type="submit" class="btn-submit" :disabled="changesSubmitting || !changesComment.trim()">
{{ changesSubmitting ? 'Sende…' : 'Zurückgeben' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="showDetailPanel && selectedTask" class="detail-overlay" @click.self="closeDetailPanel">
<aside class="detail-panel">