feat: Parent-Child TaskFlow — Delegated durch sichtbare Child-Tasks ersetzt

- Delegated State aus Board, Entities, DTOs, Frontend-Spalten und Tests entfernt
- Parent-Tasks bleiben InProgress waehrend delegierter Agentenarbeit
- Child-Tasks laufen sichtbar mit normalen States und parentTaskId
- Doku: README, Phase 3, Changelog, Controller-Kommentare angepasst
- openclaw-task-board-flow.md als Referenzdoku hinzugefuegt
- 73/73 Backend-Tests gruen, Frontend-Build gruen
This commit is contained in:
2026-06-21 21:30:52 +02:00
parent b89289989a
commit ac131f7f53
15 changed files with 464 additions and 133 deletions
+2
View File
@@ -7,6 +7,8 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {})
const send = () => {
const headers = new Headers(init.headers)
if (auth.accessToken) headers.set('Authorization', `Bearer ${auth.accessToken}`)
if (auth.isIris) headers.set('X-Agent-Id', 'iris')
else if (auth.isBao) headers.set('X-Agent-Id', 'bao')
return fetch(input, { ...init, headers, credentials: 'include' })
}
+6 -8
View File
@@ -4,7 +4,7 @@
* Fetches tasks from /api/dashboard/tasks and /api/dashboard/tasks/board
* and maps them into TaskItem[] format for the TaskStrip component.
*
* Board state: grouped by column (offen, inProgress, delegated, review, done, blocked)
* Board state: grouped by column (offen, inProgress, review, blocked, done)
* Auto-refresh: every 30 seconds.
*/
import { defineStore } from 'pinia'
@@ -34,10 +34,9 @@ export interface DashboardTaskDto {
export interface BoardGroup {
offen: DashboardTaskDto[]
inProgress: DashboardTaskDto[]
delegated: DashboardTaskDto[]
review: DashboardTaskDto[]
done: DashboardTaskDto[]
blocked: DashboardTaskDto[]
done: DashboardTaskDto[]
}
export interface AgentWorkflowOverview {
@@ -97,10 +96,9 @@ export const useTaskStore = defineStore('tasks', {
board: {
offen: [] as DashboardTaskDto[],
inProgress: [] as DashboardTaskDto[],
delegated: [] as DashboardTaskDto[],
review: [] as DashboardTaskDto[],
done: [] as DashboardTaskDto[],
blocked: [] as DashboardTaskDto[],
done: [] as DashboardTaskDto[],
} as BoardGroup,
boardLoading: false,
boardError: null as string | null,
@@ -169,10 +167,9 @@ export const useTaskStore = defineStore('tasks', {
const canonicalMap: Record<string, string> = {
offen: 'Backlog',
inProgress: 'In progress',
delegated: 'Delegated',
review: 'Review',
done: 'Done',
blocked: 'Blocked',
done: 'Done',
}
// Save previous state for rollback
@@ -188,7 +185,6 @@ export const useTaskStore = defineStore('tasks', {
const task =
findAndRemove(this.board.offen) ??
findAndRemove(this.board.inProgress) ??
findAndRemove(this.board.delegated) ??
findAndRemove(this.board.review) ??
findAndRemove(this.board.blocked) ??
findAndRemove(this.board.done)
@@ -322,6 +318,7 @@ export const useTaskStore = defineStore('tasks', {
priority?: string
assignedTo?: string
expectedFrom?: string
parentTaskId?: string | null
}) {
try {
const res = await apiFetch('/api/dashboard/tasks/agent', {
@@ -333,6 +330,7 @@ export const useTaskStore = defineStore('tasks', {
priority: data.priority ?? 'Medium',
assignedTo: data.assignedTo ?? null,
expectedFrom: data.expectedFrom ?? null,
parentTaskId: data.parentTaskId ?? null,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
+2 -2
View File
@@ -37,8 +37,8 @@ export interface RoutingTarget {
detail: string
}
export type TaskState = 'Backlog' | 'In progress' | 'Blocked' | 'Done'
export const TASK_STATES: TaskState[] = ['Backlog', 'In progress', 'Blocked', 'Done']
export type TaskState = 'Backlog' | 'In progress' | 'Review' | 'Blocked' | 'Done'
export const TASK_STATES: TaskState[] = ['Backlog', 'In progress', 'Review', 'Blocked', 'Done']
export interface OperationsSnapshot {
generatedAt: string
+63 -72
View File
@@ -3,13 +3,13 @@
* TaskBoardView Linear-style Kanban Board
* Galaxy/Dashboard V2 styled edition.
*
* 6 columns: Offen, In Bearbeitung, Delegiert, Review, Blockiert, Erledigt
* 5 columns: Offen, In Bearbeitung, Review, Blockiert, Erledigt
* HTML5 Drag & Drop (no external lib)
*
* Agent-Workflow Features:
* - Agent-Tasks have a 🤖 badge
* - ExpectedFrom field shows who is expected to act next
* - Stale-task warning banner at top (InProgress/Delegated > 2h)
* - Stale-task warning banner at top (InProgress > 2h)
* - Waiting section for Iris overview
*/
import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
@@ -157,7 +157,6 @@ function statusTone(state: string): string {
case 'done': return 'is-done'
case 'blocked': return 'is-blocked'
case 'review': return 'is-review'
case 'delegated': return 'is-delegated'
case 'in progress': return 'is-progress'
default: return 'is-backlog'
}
@@ -183,7 +182,6 @@ function flattenBoard() {
return [
...taskStore.board.offen,
...taskStore.board.inProgress,
...taskStore.board.delegated,
...taskStore.board.review,
...taskStore.board.blocked,
...taskStore.board.done,
@@ -250,8 +248,45 @@ function relativeTime(date?: string | null): string {
return `vor ${days} d`
}
function childStatusSummary(taskId: string): string {
const children = allBoardTasks.value.filter(task => task.parentTaskId === taskId)
if (!children.length) return ''
const counts = {
inProgress: children.filter(task => task.state === 'In progress').length,
review: children.filter(task => task.state === 'Review').length,
blocked: children.filter(task => task.state === 'Blocked').length,
done: children.filter(task => task.state === 'Done').length,
}
const parts = [] as string[]
if (counts.inProgress) parts.push(`${counts.inProgress} in Arbeit`)
if (counts.review) parts.push(`${counts.review} im Review`)
if (counts.blocked) parts.push(`${counts.blocked} blockiert`)
if (counts.done) parts.push(`${counts.done} erledigt`)
return parts.length ? `Child-Tasks: ${parts.join(' · ')}` : `Child-Tasks: ${children.length}`
}
function activityHint(task: BoardTask): string {
return task.lastActivityMessage?.trim() || (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status')
return task.lastActivityMessage?.trim()
|| childStatusSummary(task.id)
|| (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status')
}
function hasChildTasks(taskId: string): boolean {
return allBoardTasks.value.some(task => task.parentTaskId === taskId)
}
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 ───────────────────────────── */
@@ -399,7 +434,7 @@ onUnmounted(() => {
<!-- Stale Warning Banner -->
<div v-if="staleCount > 0" class="stale-banner">
<AlertTriangle :size="14" />
<span><strong>{{ staleCount }} Task(s)</strong> sind stale (InBearbeitung/Delegiert &gt; 2h ohne Update).</span>
<span><strong>{{ staleCount }} Task(s)</strong> sind stale (In Bearbeitung &gt; 2h ohne Update).</span>
<button class="stale-dismiss" @click="showIrisPanel = true">Ansehen</button>
</div>
@@ -514,14 +549,14 @@ onUnmounted(() => {
<span
v-if="task.assignedTo"
class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
:class="assigneeClass(task.assignedTo)"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
{{ 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" class="card-progress-hint">{{ activityHint(task) }}</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>
@@ -563,69 +598,20 @@ onUnmounted(() => {
<span
v-if="task.assignedTo"
class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
:class="assigneeClass(task.assignedTo)"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
{{ 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" class="card-progress-hint">{{ activityHint(task) }}</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 === 'delegated' }"
@dragover="onDragOver($event, 'delegated')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'delegated')"
>
<div class="col-header">
<span class="col-icon" style="background: var(--a-purple); box-shadow: 0 0 0 2px rgba(181,87,246,.25);"></span>
<span class="col-name">Delegiert</span>
<span class="col-count">{{ taskStore.board.delegated.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.delegated"
: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="task.assignedTo"
class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
</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" 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>
</div>
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'review' }"
@@ -661,14 +647,14 @@ onUnmounted(() => {
<span
v-if="task.assignedTo"
class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
:class="assigneeClass(task.assignedTo)"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
{{ 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" class="card-progress-hint">{{ activityHint(task) }}</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>
@@ -705,14 +691,14 @@ onUnmounted(() => {
<span
v-if="task.assignedTo"
class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
:class="assigneeClass(task.assignedTo)"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
{{ 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" class="card-progress-hint">{{ activityHint(task) }}</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>
@@ -754,13 +740,14 @@ onUnmounted(() => {
<span
v-if="task.assignedTo"
class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
:class="assigneeClass(task.assignedTo)"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
{{ 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>
@@ -849,11 +836,12 @@ onUnmounted(() => {
<span>#{{ selectedTask.id.slice(0, 8) }}</span>
<span v-if="selectedTask.isAgentTask" class="meta-agent-tag">🤖 Agent-Task</span>
<span v-if="selectedTask.expectedFrom" class="meta-expected"> Erwartet: {{ selectedTask.expectedFrom }}</span>
<span v-if="selectedTask.parentTaskId" class="meta-expected"> Child-Task</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">
<div v-if="selectedTask.isAgentTask || hasChildTasks(selectedTask.id)" class="detail-progress-banner">
<strong>Letzter Fortschritt:</strong> {{ activityHint(selectedTask) }}
</div>
</div>
@@ -914,7 +902,6 @@ onUnmounted(() => {
>
<option value="Backlog">Offen</option>
<option value="In progress">In Bearbeitung</option>
<option value="Delegated">Delegiert</option>
<option value="Review">Review</option>
<option value="Blocked">Blockiert</option>
<option value="Done">Erledigt</option>
@@ -970,6 +957,10 @@ onUnmounted(() => {
<dt>Erwartet von</dt>
<dd>{{ selectedTask.expectedFrom }}</dd>
</div>
<div v-if="selectedTask.parentTaskId">
<dt>Task-Typ</dt>
<dd>Sichtbare Child-Task</dd>
</div>
</dl>
</section>
@@ -1078,6 +1069,7 @@ select:disabled { opacity: .45; cursor: not-allowed; }
.assignee { font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; }
.assignee-iris { background: rgba(147, 51, 234, .12); color: #c084fc; }
.assignee-bao { background: rgba(59, 130, 246, .12); color: #60a5fa; }
.assignee-agent { background: rgba(16, 185, 129, .12); color: #6ee7b7; }
.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); }
@@ -1123,7 +1115,6 @@ select:disabled { opacity: .45; cursor: not-allowed; }
.detail-state-pill { width: fit-content; border-radius: 999px; padding: 5px 10px; font-size: 11px; font-weight: 700; letter-spacing: .03em; border: 1px solid transparent; }
.detail-state-pill.is-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
.detail-state-pill.is-progress { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.detail-state-pill.is-delegated { color: #d8b4fe; background: rgba(168,85,247,.12); border-color: rgba(168,85,247,.25); }
.detail-state-pill.is-review { color: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
.detail-state-pill.is-blocked { color: #fda4af; background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
.detail-state-pill.is-done { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
+29 -8
View File
@@ -92,7 +92,6 @@ function statusLabel(state: string): string {
const map: Record<string, string> = {
'Backlog': 'Offen',
'In progress': 'In Bearbeitung',
'Delegated': 'Delegiert',
'Review': 'Review',
'Blocked': 'Blockiert',
'Done': 'Erledigt',
@@ -105,7 +104,6 @@ function statusClass(state: string): string {
if (s === 'done') return 'is-done'
if (s === 'blocked') return 'is-blocked'
if (s === 'review') return 'is-review'
if (s === 'delegated') return 'is-delegated'
if (s === 'in progress') return 'is-progress'
return 'is-backlog'
}
@@ -148,8 +146,27 @@ function relativeTime(date?: string | null): string {
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')
function childStatusSummary(taskId: string): string {
const childItems = children.value.filter(child => child.parentTaskId === taskId)
if (!childItems.length) return ''
const counts = {
inProgress: childItems.filter(child => child.state === 'In progress').length,
review: childItems.filter(child => child.state === 'Review').length,
blocked: childItems.filter(child => child.state === 'Blocked').length,
done: childItems.filter(child => child.state === 'Done').length,
}
const parts = [] as string[]
if (counts.inProgress) parts.push(`${counts.inProgress} in Arbeit`)
if (counts.review) parts.push(`${counts.review} im Review`)
if (counts.blocked) parts.push(`${counts.blocked} blockiert`)
if (counts.done) parts.push(`${counts.done} erledigt`)
return parts.length ? `Child-Tasks: ${parts.join(' · ')}` : `Child-Tasks: ${childItems.length}`
}
function progressHint(taskLike: Pick<TaskDto, 'id' | 'lastActivityMessage' | 'expectedFrom'>): string {
return taskLike.lastActivityMessage?.trim() || childStatusSummary(taskLike.id) || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
}
/* ── API calls ───────────────────────────────── */
@@ -362,6 +379,7 @@ function handleKeydown(e: KeyboardEvent) {
<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>
<span v-if="task.parentTaskId" class="meta-chip"> Sichtbare Child-Task</span>
</div>
</div>
@@ -390,7 +408,7 @@ function handleKeydown(e: KeyboardEvent) {
</span>
</div>
<div v-if="task.isAgentTask" class="progress-banner">
<div v-if="task.isAgentTask || childStatusSummary(task.id)" class="progress-banner">
<strong>Letzter Fortschritt:</strong> {{ progressHint(task) }}
</div>
@@ -424,7 +442,7 @@ function handleKeydown(e: KeyboardEvent) {
<option value="Medium">Medium</option>
<option value="Low">Low</option>
</select>
<input v-model="subtaskAssign" class="galaxy-input narrow" placeholder="Zuständig (bao, iris…)" />
<input v-model="subtaskAssign" class="galaxy-input narrow" placeholder="Zuständig (bao, iris, researcher…)" />
<button class="btn-primary btn-sm" @click="createSubtask" :disabled="creatingSubtask">
{{ creatingSubtask ? 'Erstelle…' : 'Anlegen' }}
</button>
@@ -455,6 +473,8 @@ function handleKeydown(e: KeyboardEvent) {
>
<option value="Backlog">Offen</option>
<option value="In progress">In Arbeit</option>
<option value="Review">Review</option>
<option value="Blocked">Blockiert</option>
<option value="Done">Erledigt</option>
</select>
<button
@@ -520,7 +540,6 @@ function handleKeydown(e: KeyboardEvent) {
<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>
<option value="Review">Review</option>
<option value="Blocked">Blockiert</option>
<option value="Done">Erledigt</option>
@@ -543,6 +562,8 @@ function handleKeydown(e: KeyboardEvent) {
<option value="programmer">🛠 Programmer</option>
<option value="reviewer">🔎 Reviewer</option>
<option value="architekt">🏛 Architekt</option>
<option value="researcher">🔬 Researcher</option>
<option value="executor"> Executor</option>
</select>
</label>
<label class="sidebar-field">
@@ -559,6 +580,7 @@ function handleKeydown(e: KeyboardEvent) {
<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>
<div v-if="task.parentTaskId"><dt>Task-Typ</dt><dd>Sichtbare Child-Task</dd></div>
</dl>
</section>
@@ -678,7 +700,6 @@ function handleKeydown(e: KeyboardEvent) {
.state-badge.is-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
.state-badge.is-progress { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.state-badge.is-delegated { color: #d8b4fe; background: rgba(168,85,247,.12); border-color: rgba(168,85,247,.25); }
.state-badge.is-review { color: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
.state-badge.is-blocked { color: #fda4af; background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
.state-badge.is-done { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }