feat: ship agent progress visibility
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 4s

This commit is contained in:
2026-06-20 20:22:54 +02:00
parent 3dd745586b
commit adae7ba26d
9 changed files with 202 additions and 45 deletions
+2 -2
View File
@@ -262,9 +262,9 @@ public class DashboardController(
[HttpGet("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
{
var task = await taskService.GetByIdAsync(id, ct);
var task = await taskService.GetDashboardTaskByIdAsync(id, ct);
if (task is null) return NotFound(new { error = "Task not found." });
return Ok(MapToDto(task));
return Ok(task);
}
[HttpGet("tasks/{id:guid}/activity")]
+3 -1
View File
@@ -91,7 +91,9 @@ public sealed record DashboardTaskDto(
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
bool IsAgentTask = false,
string? ExpectedFrom = null
string? ExpectedFrom = null,
string? LastActivityMessage = null,
DateTimeOffset? LastActivityAt = null
);
public sealed record CreateDashboardTaskRequest(
@@ -8,6 +8,18 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
{
var ids = taskIds.Distinct().ToList();
if (ids.Count == 0)
return Task.FromResult(new List<ActivityEvent>());
return db.Activity.AsNoTracking()
.Where(x => x.TaskId.HasValue && ids.Contains(x.TaskId.Value))
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(ct);
}
public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
{
@@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories;
public interface IActivityRepository
{
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default);
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
+1
View File
@@ -36,6 +36,7 @@ public interface ITaskService
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
// Agent Workflow Overview
Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default);
+37 -16
View File
@@ -20,6 +20,15 @@ public sealed class TaskService(
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await taskRepo.GetByIdAsync(id, ct);
public async Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return null;
var activity = await activityRepo.GetRecentForTasksAsync([task.Id], ct);
return MapToDtoWithActivity(task, activity);
}
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> await taskRepo.GetPendingApprovalAsync(ct);
@@ -162,35 +171,32 @@ public sealed class TaskService(
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
var waitingForBao = agentTasks
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
List<DashboardTaskDto> map(IEnumerable<WorkTask> tasks)
=> tasks.Select(task => MapToDtoWithActivity(task, activity)).ToList();
var waitingForBao = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.Select(MapToDto)
.ToList();
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForIris = agentTasks
var waitingForIris = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.Select(MapToDto)
.ToList();
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForOthers = agentTasks
var waitingForOthers = map(agentTasks
.Where(t =>
{
var expected = (t.ExpectedFrom ?? "").ToLowerInvariant();
return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase);
})
.Select(MapToDto)
.ToList();
}));
var staleTasks = agentTasks
var staleTasks = map(agentTasks
.Where(t =>
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) ||
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
t.UpdatedAt < threshold)
.Select(MapToDto)
.ToList();
t.UpdatedAt < threshold));
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
staleTasks, staleThreshold);
@@ -533,6 +539,21 @@ public sealed class TaskService(
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity)
{
var last = activity
.Where(e => e.TaskId == t.Id)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefault();
return new DashboardTaskDto(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom,
last?.Message,
last?.CreatedAt);
}
/// <summary>
/// Validates AssignedTo — only recognized agent values are accepted.
/// Returns null for invalid values.
+2
View File
@@ -27,6 +27,8 @@ export interface DashboardTaskDto {
updatedAt: string
isAgentTask?: boolean
expectedFrom?: string | null
lastActivityMessage?: string | null
lastActivityAt?: string | null
}
export interface BoardGroup {
+55 -14
View File
@@ -13,7 +13,7 @@
* - 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 } from '@lucide/vue'
import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useTaskStore } from '../stores/tasks'
@@ -238,6 +238,22 @@ function hoursSince(dateStr: string): number {
return Math.round((now - then) / 3600000)
}
function relativeTime(date?: string | null): string {
if (!date) return 'keine Updates'
const diffMs = Date.now() - new Date(date).getTime()
const mins = Math.max(0, Math.round(diffMs / 60000))
if (mins < 1) return 'gerade eben'
if (mins < 60) return `vor ${mins} min`
const hours = Math.round(mins / 60)
if (hours < 24) return `vor ${hours} h`
const days = Math.round(hours / 24)
return `vor ${days} d`
}
function activityHint(task: BoardTask): string {
return task.lastActivityMessage?.trim() || (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status')
}
/* ── Task Navigation ───────────────────────────── */
function navigateToTask(taskId: string) {
router.push('/tasks/' + taskId)
@@ -401,9 +417,12 @@ onUnmounted(() => {
Warte auf Iris <span class="section-count">{{ waitingForIrisCount }}</span>
</div>
<div v-if="taskStore.waitingForIrisTasks.length === 0" class="iris-empty">Keine Tasks</div>
<div v-for="t in taskStore.waitingForIrisTasks" :key="t.id" class="iris-task-row">
<div v-for="t in taskStore.waitingForIrisTasks" :key="t.id" class="iris-task-row progress-row">
<div>
<span class="iris-task-title">{{ t.title }}</span>
<span class="iris-task-meta">{{ stateLabel(t.state) }}</span>
<div class="iris-task-progress">{{ activityHint(t) }}</div>
</div>
<span class="iris-task-meta">{{ relativeTime(t.lastActivityAt ?? t.updatedAt) }}</span>
</div>
</section>
@@ -413,9 +432,12 @@ onUnmounted(() => {
Warte auf Bao <span class="section-count">{{ waitingForBaoCount }}</span>
</div>
<div v-if="taskStore.waitingForBaoTasks.length === 0" class="iris-empty">Keine Tasks</div>
<div v-for="t in taskStore.waitingForBaoTasks" :key="t.id" class="iris-task-row">
<div v-for="t in taskStore.waitingForBaoTasks" :key="t.id" class="iris-task-row progress-row">
<div>
<span class="iris-task-title">{{ t.title }}</span>
<span class="iris-task-meta">{{ stateLabel(t.state) }}</span>
<div class="iris-task-progress">{{ activityHint(t) }}</div>
</div>
<span class="iris-task-meta">{{ relativeTime(t.lastActivityAt ?? t.updatedAt) }}</span>
</div>
</section>
@@ -425,8 +447,11 @@ onUnmounted(() => {
Warte auf andere <span class="section-count">{{ taskStore.waitingForOthersTasks.length }}</span>
</div>
<div v-if="taskStore.waitingForOthersTasks.length === 0" class="iris-empty">Keine Tasks</div>
<div v-for="t in taskStore.waitingForOthersTasks" :key="t.id" class="iris-task-row">
<div v-for="t in taskStore.waitingForOthersTasks" :key="t.id" class="iris-task-row progress-row">
<div>
<span class="iris-task-title">{{ t.title }}</span>
<div class="iris-task-progress">{{ activityHint(t) }}</div>
</div>
<span class="iris-task-meta">{{ expectedFromLabel(t.expectedFrom) }}</span>
</div>
</section>
@@ -437,8 +462,11 @@ onUnmounted(() => {
Stale Tasks <span class="section-count stale-count">{{ staleCount }}</span>
</div>
<div v-if="taskStore.staleTasksList.length === 0" class="iris-empty">Keine stale Tasks</div>
<div v-for="t in taskStore.staleTasksList" :key="t.id" class="iris-task-row stale-row">
<div v-for="t in taskStore.staleTasksList" :key="t.id" class="iris-task-row stale-row progress-row">
<div>
<span class="iris-task-title">{{ t.title }}</span>
<div class="iris-task-progress">{{ activityHint(t) }}</div>
</div>
<span class="iris-task-meta stale-meta">{{ hoursSince(t.updatedAt) }}h offen</span>
</div>
</section>
@@ -493,7 +521,8 @@ onUnmounted(() => {
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
<div v-if="task.isAgentTask" 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>
@@ -541,7 +570,8 @@ onUnmounted(() => {
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
<div v-if="task.isAgentTask" 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>
@@ -589,7 +619,8 @@ onUnmounted(() => {
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.delegated.length" class="empty-col">Keine delegierten Aufgaben</div>
</div>
@@ -637,7 +668,8 @@ onUnmounted(() => {
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
<div v-if="task.isAgentTask" 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>
@@ -680,7 +712,8 @@ onUnmounted(() => {
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</div>
<div v-if="task.isAgentTask" 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>
@@ -728,7 +761,7 @@ onUnmounted(() => {
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div class="card-meta">{{ new Date(task.createdAt).toLocaleDateString('de-DE') }}</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>
@@ -818,6 +851,10 @@ onUnmounted(() => {
<span v-if="selectedTask.expectedFrom" class="meta-expected"> Erwartet: {{ selectedTask.expectedFrom }}</span>
<span><Clock3 :size="13" /> Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }}</span>
<span><CalendarDays :size="13" /> Erstellt {{ formatDate(selectedTask.createdAt) }}</span>
<span v-if="selectedTask.isAgentTask"><MessageSquareText :size="13" /> Letzter Status {{ relativeTime(selectedTask.lastActivityAt ?? selectedTask.updatedAt) }}</span>
</div>
<div v-if="selectedTask.isAgentTask" class="detail-progress-banner">
<strong>Letzter Fortschritt:</strong> {{ activityHint(selectedTask) }}
</div>
</div>
@@ -1003,7 +1040,9 @@ onUnmounted(() => {
.iris-empty { font-size: 11px; color: var(--tx-3); font-style: italic; padding: 8px; text-align: center; }
.iris-task-row { padding: 6px 8px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.iris-task-row:last-child { border-bottom: none; }
.iris-task-title { font-size: 11.5px; font-weight: 500; color: var(--tx); }
.progress-row { align-items: flex-start; }
.iris-task-title { font-size: 11.5px; font-weight: 500; color: var(--tx); display: block; }
.iris-task-progress { margin-top: 4px; font-size: 10px; color: var(--tx-3); line-height: 1.35; }
.iris-task-meta { font-size: 10px; color: var(--tx-3); white-space: nowrap; }
.stale-row { background: rgba(244,63,94,.05); border-radius: 4px; }
.stale-meta { color: #fda4af; font-weight: 600; }
@@ -1041,6 +1080,7 @@ select:disabled { opacity: .45; cursor: not-allowed; }
.assignee-bao { background: rgba(59, 130, 246, .12); color: #60a5fa; }
.card-title { font-size: 12.5px; font-weight: 600; color: var(--tx); line-height: 1.4; word-break: break-word; font-family: 'Manrope', sans-serif; }
.card-preview { margin-top: 6px; font-size: 11px; line-height: 1.45; color: var(--tx-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card-progress-hint { margin-top: 7px; font-size: 10.5px; color: var(--tx-2); line-height: 1.4; padding: 6px 8px; border-radius: 8px; background: rgba(124,108,255,.07); border: 1px solid rgba(124,108,255,.12); }
.card-meta { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--tx-3); margin-top: 5px; font-variant-numeric: tabular-nums; }
.empty-col, .detail-empty { display: flex; align-items: center; justify-content: center; padding: 24px 12px; font-size: 11px; color: var(--tx-3); font-style: italic; font-family: 'Manrope', sans-serif; }
@@ -1092,6 +1132,7 @@ select:disabled { opacity: .45; cursor: not-allowed; }
.detail-meta-row span { display: inline-flex; align-items: center; gap: 5px; }
.meta-agent-tag { color: #c084fc; }
.meta-expected { color: #a78bfa; }
.detail-progress-banner { padding: 10px 12px; border-radius: 12px; background: rgba(124,108,255,.08); border: 1px solid rgba(124,108,255,.14); color: var(--tx-2); font-size: 12px; }
.detail-section { background: rgba(255,255,255,.02); border: 1px solid var(--line); border-radius: 16px; padding: 16px; }
.detail-section-header, .sidebar-heading { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: var(--tx-2); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.detail-textarea { width: 100%; min-height: 180px; border-radius: 12px; border: 1px solid var(--line); background: rgba(10,9,24,.55); color: var(--tx); padding: 14px; font-size: 14px; line-height: 1.6; outline: none; box-sizing: border-box; }
+79 -2
View File
@@ -12,9 +12,10 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowLeft, Clock3, CalendarDays, ListChecks, Save, Plus,
X, Link2, MessageSquare, Trash2, CheckCircle, AlertCircle,
X, Link2, MessageSquare, Trash2, CheckCircle, AlertCircle, ShieldBan,
} from '@lucide/vue'
import { apiFetch } from '../services/api'
import { useAuthStore } from '../stores/auth'
/* ── Types ──────────────────────────────────── */
interface TaskDto {
@@ -29,6 +30,10 @@ interface TaskDto {
dueDate: string | null
createdAt: string
updatedAt: string
isAgentTask?: boolean
expectedFrom?: string | null
lastActivityMessage?: string | null
lastActivityAt?: string | null
}
interface ActivityEntry {
@@ -42,6 +47,7 @@ interface ActivityEntry {
/* ── State ───────────────────────────────────── */
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const taskId = computed(() => route.params.id as string)
const task = ref<TaskDto | null>(null)
@@ -79,6 +85,7 @@ const postingComment = ref(false)
/* ── Delete state ───────────────────────────── */
const deletingTask = ref('') // id of subtask being deleted
const canChangeState = computed(() => authStore.isIris || authStore.isBao)
/* ── Helpers ────────────────────────────────── */
function statusLabel(state: string): string {
@@ -129,6 +136,22 @@ function toDateInput(date?: string | null): string {
return new Date(date).toISOString().slice(0, 10)
}
function relativeTime(date?: string | null): string {
if (!date) return 'keine Updates'
const diffMs = Date.now() - new Date(date).getTime()
const mins = Math.max(0, Math.round(diffMs / 60000))
if (mins < 1) return 'gerade eben'
if (mins < 60) return `vor ${mins} min`
const hours = Math.round(mins / 60)
if (hours < 24) return `vor ${hours} h`
const days = Math.round(hours / 24)
return `vor ${days} d`
}
function progressHint(taskLike: Pick<TaskDto, 'lastActivityMessage' | 'expectedFrom'>): string {
return taskLike.lastActivityMessage?.trim() || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
}
/* ── API calls ───────────────────────────────── */
async function loadTask() {
loading.value = true
@@ -188,6 +211,9 @@ async function saveTask() {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
// Also update state if changed
if (form.state !== task.value.state) {
if (!canChangeState.value) {
form.state = task.value.state
} else {
const stateRes = await apiFetch(`/api/dashboard/tasks/${taskId.value}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -195,6 +221,7 @@ async function saveTask() {
})
if (!stateRes.ok) throw new Error('Status-Update fehlgeschlagen')
}
}
saveSuccess.value = true
await loadTask()
} catch (e) {
@@ -236,6 +263,7 @@ async function createSubtask() {
}
async function updateSubtaskState(subtaskId: string, newState: string) {
if (!canChangeState.value) return
try {
await apiFetch(`/api/dashboard/tasks/${subtaskId}/status`, {
method: 'PATCH',
@@ -332,9 +360,16 @@ function handleKeydown(e: KeyboardEvent) {
<span class="state-badge" :class="statusClass(task.state)">{{ statusLabel(task.state) }}</span>
<span class="meta-chip" v-if="task.source">Quelle: {{ task.source }}</span>
<span class="meta-chip">{{ task.priority }} Priorität</span>
<span v-if="task.isAgentTask" class="meta-chip">🤖 Agent-Task</span>
<span v-if="task.expectedFrom" class="meta-chip"> Erwartet: {{ task.expectedFrom }}</span>
</div>
</div>
<div v-if="!canChangeState" class="readonly-banner">
<ShieldBan :size="14" />
Inhalte sind editierbar, Statuswechsel bleiben Bao/Iris vorbehalten.
</div>
<div class="detail-body">
<!-- Main Column -->
<div class="detail-main">
@@ -349,6 +384,14 @@ function handleKeydown(e: KeyboardEvent) {
<CalendarDays :size="13" />
Aktualisiert {{ formatDate(task.updatedAt, true) }}
</span>
<span v-if="task.isAgentTask">
<MessageSquare :size="13" />
Letzter Status {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}
</span>
</div>
<div v-if="task.isAgentTask" class="progress-banner">
<strong>Letzter Fortschritt:</strong> {{ progressHint(task) }}
</div>
<!-- Description -->
@@ -407,6 +450,7 @@ function handleKeydown(e: KeyboardEvent) {
<select
:value="child.state"
class="state-select-mini"
:disabled="!canChangeState"
@change="updateSubtaskState(child.id, ($event.target as HTMLSelectElement).value)"
>
<option value="Backlog">Offen</option>
@@ -473,7 +517,7 @@ function handleKeydown(e: KeyboardEvent) {
<div class="sidebar-head">Eigenschaften</div>
<label class="sidebar-field">
<span>Status</span>
<select v-model="form.state" class="galaxy-input galaxy-select">
<select v-model="form.state" class="galaxy-input galaxy-select" :disabled="!canChangeState">
<option value="Backlog">Offen</option>
<option value="In progress">In Bearbeitung</option>
<option value="Delegated">Delegiert</option>
@@ -514,6 +558,7 @@ function handleKeydown(e: KeyboardEvent) {
<div><dt>Quelle</dt><dd>{{ task.source || '—' }}</dd></div>
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div>
<div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div>
<div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div>
</dl>
</section>
@@ -647,6 +692,19 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid rgba(150, 140, 255, 0.08);
}
.readonly-banner {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(147,51,234,.08);
border: 1px solid rgba(147,51,234,.18);
color: #c084fc;
font-size: 12px;
}
/* ── Detail Body Grid ────────────────────────── */
.detail-body {
display: grid;
@@ -694,6 +752,15 @@ function handleKeydown(e: KeyboardEvent) {
gap: 5px;
}
.progress-banner {
padding: 12px 14px;
border-radius: 12px;
background: rgba(124, 108, 255, 0.08);
border: 1px solid rgba(124, 108, 255, 0.14);
color: #a8a3d6;
font-size: 12px;
}
/* ── Sections ──────────────────────────── */
.section-card {
border: 1px solid rgba(150, 140, 255, 0.10);
@@ -830,6 +897,11 @@ function handleKeydown(e: KeyboardEvent) {
color: #ece9ff;
}
.state-select-mini:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading-mini {
display: flex;
align-items: center;
@@ -1025,6 +1097,11 @@ function handleKeydown(e: KeyboardEvent) {
color: #ece9ff;
}
.galaxy-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
display: inline-flex;
align-items: center;