feat: board-first orchestration with Gateway Bridge, live-update, and flow-board
- GatewayBridgeController: MCP-artiger Kommando-Adapter für Agent-zu-Backend - TaskBridgeService + LiveUpdateService: SSE Live-Sync + Bridge-Kommandos - FlowBoard.vue: Board-first orchestration dashboard panel - live-sync.ts store + live.ts service: SSE-basierte Live-Updates - Nullability-Warnung in HealthController.cs gefixt - nginx.conf: SSE-Proxy + CORS für Bridge-Endpunkte - .gitignore: pnpm/corepack local caches ausgeschlossen - docs: architecture-board-first-orchestration.md hinzugefügt - README: Backend Bridge API dokumentiert
This commit is contained in:
@@ -24,8 +24,37 @@ server {
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# Bridge-Endpunkte (Agent-zu-Backend): separater Pfad ohne CSP-Einschränkungen
|
||||
# für Gateway-Calls. Kein Caching, kein Buffering.
|
||||
location /api/bridge/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Agent-Id $http_x_agent_id;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# Dashboard SSE stream: single dedicated non-buffered block.
|
||||
location = /api/dashboard/live {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Accel-Buffering no always;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
@@ -9,6 +9,11 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {})
|
||||
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')
|
||||
// Set Content-Type for JSON body requests — needed because fetch() defaults
|
||||
// to text/plain for string bodies, which ASP.NET rejects for [FromBody] binding.
|
||||
if (typeof init.body === 'string' && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
return fetch(input, { ...init, headers, credentials: 'include' })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { apiFetch } from './api'
|
||||
|
||||
export type LiveEventName = 'snapshot' | 'update' | 'heartbeat'
|
||||
export type LiveMode = 'live' | 'polling'
|
||||
|
||||
export interface LiveCursorDto {
|
||||
sequence: number
|
||||
timestamp: string
|
||||
mode: LiveMode
|
||||
}
|
||||
|
||||
export interface LiveUpdateEnvelope {
|
||||
type: string
|
||||
timestamp: string
|
||||
payload: unknown
|
||||
sequence: number
|
||||
channel: string
|
||||
}
|
||||
|
||||
export interface DashboardLiveEventDto {
|
||||
envelope: LiveUpdateEnvelope
|
||||
cursor: LiveCursorDto
|
||||
}
|
||||
|
||||
export interface OpenDashboardLiveStreamResult {
|
||||
closed: Promise<void>
|
||||
}
|
||||
|
||||
export async function openDashboardLiveStream(
|
||||
onEvent: (event: LiveEventName, data: unknown) => void,
|
||||
options: { forUser?: string; notificationLimit?: number; afterSequence?: number | null; signal?: AbortSignal } = {},
|
||||
): Promise<OpenDashboardLiveStreamResult> {
|
||||
const params = new URLSearchParams({
|
||||
forUser: options.forUser ?? 'bao',
|
||||
notificationLimit: String(options.notificationLimit ?? 50),
|
||||
})
|
||||
if (typeof options.afterSequence === 'number' && Number.isFinite(options.afterSequence) && options.afterSequence > 0) {
|
||||
params.set('afterSequence', String(options.afterSequence))
|
||||
}
|
||||
|
||||
const response = await apiFetch(`/api/dashboard/live?${params}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||
signal: options.signal,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Live stream unavailable: HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
const flushBlock = (block: string) => {
|
||||
const lines = block.split('\n')
|
||||
let eventName: LiveEventName = 'update'
|
||||
const dataLines: string[] = []
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd()
|
||||
if (line.startsWith('event:')) eventName = line.slice(6).trim() as LiveEventName
|
||||
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
|
||||
}
|
||||
|
||||
if (!dataLines.length) return
|
||||
try {
|
||||
onEvent(eventName, JSON.parse(dataLines.join('\n')))
|
||||
} catch (error) {
|
||||
console.warn('[live] failed to parse event payload', error)
|
||||
}
|
||||
}
|
||||
|
||||
const closed = (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const parts = buffer.split('\n\n')
|
||||
buffer = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
if (part.trim()) flushBlock(part)
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
flushBlock(buffer)
|
||||
buffer = ''
|
||||
}
|
||||
})()
|
||||
|
||||
return { closed }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { openDashboardLiveStream } from '../services/live'
|
||||
import type { BoardGroup, DashboardTaskDto } from './tasks'
|
||||
import type { NotificationItem } from './notifications'
|
||||
import type { TaskItem } from '../components/dashboard/v2/types'
|
||||
import { useTaskStore } from './tasks'
|
||||
import { useNotificationStore } from './notifications'
|
||||
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
|
||||
|
||||
interface NotificationSnapshotDto {
|
||||
notifications: NotificationItem[]
|
||||
unreadCount: number
|
||||
forUser: string
|
||||
}
|
||||
|
||||
interface DashboardLiveSnapshotDto {
|
||||
board: BoardGroup
|
||||
notifications: NotificationSnapshotDto
|
||||
cursor: LiveCursorDto
|
||||
}
|
||||
|
||||
function isBoardGroup(value: unknown): value is BoardGroup {
|
||||
const v = value as BoardGroup
|
||||
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
|
||||
}
|
||||
|
||||
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
|
||||
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
|
||||
}
|
||||
|
||||
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
agent: t.assignedTo ?? '—',
|
||||
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
|
||||
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
|
||||
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
|
||||
detail: t.detail,
|
||||
source: t.source,
|
||||
}
|
||||
}
|
||||
|
||||
export const useLiveSyncStore = defineStore('liveSync', {
|
||||
state: () => ({
|
||||
connected: false,
|
||||
connecting: false,
|
||||
lastEventAt: null as string | null,
|
||||
lastHeartbeatAt: null as string | null,
|
||||
error: null as string | null,
|
||||
controller: null as AbortController | null,
|
||||
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
mode: 'polling' as 'polling' | 'live',
|
||||
lastSequence: 0,
|
||||
reconnectAttempts: 0,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
liveIndicatorLabel: (state) => {
|
||||
if (state.connecting) return 'Verbinde…'
|
||||
if (state.connected) return `Live · #${state.lastSequence}`
|
||||
return state.mode === 'polling' ? 'Polling' : 'Offline'
|
||||
},
|
||||
connectionHealth: (state) => {
|
||||
if (state.connected) return 'healthy'
|
||||
if (state.connecting) return 'connecting'
|
||||
return 'degraded'
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async connect(forUser = 'bao') {
|
||||
if (this.connecting || this.connected) return
|
||||
this.connecting = true
|
||||
this.error = null
|
||||
this.controller = new AbortController()
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
try {
|
||||
const stream = await openDashboardLiveStream((event, data) => {
|
||||
this.lastEventAt = new Date().toISOString()
|
||||
|
||||
if (event === 'heartbeat') {
|
||||
const cursor = data as LiveCursorDto
|
||||
this.lastHeartbeatAt = cursor.timestamp
|
||||
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
|
||||
return
|
||||
}
|
||||
|
||||
if (event === 'snapshot') {
|
||||
const snapshot = data as DashboardLiveSnapshotDto
|
||||
taskStore.board = snapshot.board
|
||||
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
|
||||
notificationStore.notifications = snapshot.notifications.notifications
|
||||
notificationStore.unreadCount = snapshot.notifications.unreadCount
|
||||
this.lastSequence = snapshot.cursor.sequence
|
||||
this.connected = true
|
||||
this.mode = 'live'
|
||||
this.reconnectAttempts = 0
|
||||
taskStore.stopBoardPolling()
|
||||
return
|
||||
}
|
||||
|
||||
const eventDto = data as DashboardLiveEventDto
|
||||
this.applyEnvelope(eventDto.envelope, forUser)
|
||||
this.lastSequence = eventDto.cursor.sequence
|
||||
this.connected = true
|
||||
this.mode = 'live'
|
||||
this.reconnectAttempts = 0
|
||||
taskStore.stopBoardPolling()
|
||||
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
||||
|
||||
await stream.closed
|
||||
} catch (error) {
|
||||
if (this.controller?.signal.aborted) return
|
||||
console.warn('[liveSync] stream failed, falling back to polling', error)
|
||||
this.error = 'Live updates unavailable'
|
||||
this.connected = false
|
||||
this.mode = 'polling'
|
||||
taskStore.startBoardPolling()
|
||||
this.scheduleReconnect(forUser)
|
||||
} finally {
|
||||
this.connecting = false
|
||||
if (!this.controller?.signal.aborted && !this.connected) {
|
||||
this.mode = 'polling'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
|
||||
taskStore.board = envelope.payload
|
||||
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
|
||||
return
|
||||
}
|
||||
|
||||
if (envelope.type === 'notifications.snapshot') {
|
||||
const snapshot = envelope.payload as NotificationSnapshotDto
|
||||
if (snapshot.forUser !== forUser) return
|
||||
notificationStore.notifications = snapshot.notifications
|
||||
notificationStore.unreadCount = snapshot.unreadCount
|
||||
}
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
this.controller?.abort()
|
||||
this.controller = null
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.mode = 'polling'
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
scheduleReconnect(forUser = 'bao') {
|
||||
if (this.reconnectTimer) return
|
||||
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
||||
this.reconnectAttempts += 1
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect(forUser)
|
||||
}, delay)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -84,7 +84,6 @@ export const useNotificationStore = defineStore('notifications', {
|
||||
},
|
||||
|
||||
startPolling(forUser = 'bao') {
|
||||
// Unread count polling every 30s (for sidebar badge)
|
||||
if (!this.countRefreshInterval) {
|
||||
this.fetchUnreadCount(forUser)
|
||||
this.countRefreshInterval = setInterval(() => {
|
||||
|
||||
@@ -359,8 +359,12 @@ export const useTaskStore = defineStore('tasks', {
|
||||
}
|
||||
},
|
||||
|
||||
startBoardPolling() {
|
||||
if (this.boardRefreshInterval) return
|
||||
startBoardPolling(force = false) {
|
||||
if (this.boardRefreshInterval && !force) return
|
||||
if (this.boardRefreshInterval && force) {
|
||||
clearInterval(this.boardRefreshInterval)
|
||||
this.boardRefreshInterval = null
|
||||
}
|
||||
this.fetchBoard()
|
||||
this.boardRefreshInterval = setInterval(() => {
|
||||
this.fetchBoard()
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
*
|
||||
* Polling startet bei Mount, stoppt bei Unmount.
|
||||
*/
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAgentStore } from '../../stores/agents'
|
||||
import { useChatStore } from '../../stores/chat'
|
||||
import { useDashboardStore } from '../../stores/dashboard'
|
||||
import { useTaskStore } from '../../stores/tasks'
|
||||
import { useLiveSyncStore } from '../../stores/live-sync'
|
||||
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
||||
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
||||
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
||||
@@ -29,6 +31,8 @@ const agentStore = useAgentStore()
|
||||
const chatStore = useChatStore()
|
||||
const dashboardStore = useDashboardStore()
|
||||
const taskStore = useTaskStore()
|
||||
const liveSyncStore = useLiveSyncStore()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
addAgent,
|
||||
@@ -42,18 +46,21 @@ const {
|
||||
updatePositions,
|
||||
} = useFlowBoardState(agentStore, chatStore)
|
||||
|
||||
const blockedTasks = computed(() => taskStore.taskList.filter(task => task.status === 'blocked'))
|
||||
|
||||
function handleBlockerClick() {
|
||||
console.log('[FlowBoard] blocker clicked')
|
||||
if (!blockedTasks.value.length) return
|
||||
router.push('/tasks')
|
||||
}
|
||||
|
||||
function blockerLabel() {
|
||||
const blockedTask = taskStore.taskList.find(task => task.status === 'blocked')
|
||||
const blockedTask = blockedTasks.value[0]
|
||||
if (!blockedTask) return undefined
|
||||
return `${taskStore.taskList.filter(task => task.status === 'blocked').length} Blocker — ${blockedTask.title}`
|
||||
return `${blockedTasks.value.length} Blocker — ${blockedTask.title}`
|
||||
}
|
||||
|
||||
function blockerCount() {
|
||||
return taskStore.taskList.filter(task => task.status === 'blocked').length
|
||||
return blockedTasks.value.length
|
||||
}
|
||||
|
||||
/* ── Lifecycle ────────────────────────────────────── */
|
||||
@@ -62,6 +69,8 @@ onMounted(() => {
|
||||
chatStore.startPolling()
|
||||
dashboardStore.startPolling()
|
||||
taskStore.startPolling()
|
||||
taskStore.startBoardPolling()
|
||||
liveSyncStore.connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -69,6 +78,8 @@ onUnmounted(() => {
|
||||
chatStore.stopPolling()
|
||||
dashboardStore.stopPolling()
|
||||
taskStore.stopPolling()
|
||||
taskStore.stopBoardPolling()
|
||||
liveSyncStore.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { useLiveSyncStore } from '../stores/live-sync'
|
||||
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
|
||||
|
||||
const store = useNotificationStore()
|
||||
const router = useRouter()
|
||||
const liveSyncStore = useLiveSyncStore()
|
||||
|
||||
const sortedNotifications = computed(() => {
|
||||
return [...store.notifications].sort(
|
||||
@@ -53,10 +55,12 @@ function onNotificationClick(n: { id: string, taskId: string | null }) {
|
||||
|
||||
onMounted(() => {
|
||||
store.startListPolling()
|
||||
liveSyncStore.connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.stopListPolling()
|
||||
liveSyncStore.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, A
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useTaskStore } from '../stores/tasks'
|
||||
import { useLiveSyncStore } from '../stores/live-sync'
|
||||
|
||||
type BoardTask = ReturnType<typeof flattenBoard>[number]
|
||||
|
||||
@@ -32,6 +33,7 @@ type TaskFormState = {
|
||||
const authStore = useAuthStore()
|
||||
const taskStore = useTaskStore()
|
||||
const router = useRouter()
|
||||
const liveSyncStore = useLiveSyncStore()
|
||||
const showCreateModal = ref(false)
|
||||
const showDetailPanel = ref(false)
|
||||
const showIrisPanel = ref(false)
|
||||
@@ -215,6 +217,8 @@ function hydrateDetailForm(task: BoardTask | null) {
|
||||
const staleCount = computed(() => taskStore.staleTasksList.length)
|
||||
const waitingForIrisCount = computed(() => taskStore.waitingForIrisTasks.length)
|
||||
const waitingForBaoCount = computed(() => taskStore.waitingForBaoTasks.length)
|
||||
const liveModeLabel = computed(() => liveSyncStore.liveIndicatorLabel)
|
||||
const liveModeClass = computed(() => `live-pill-${liveSyncStore.connectionHealth}`)
|
||||
|
||||
function expectedFromLabel(expected: string | null | undefined): string {
|
||||
if (!expected) return ''
|
||||
@@ -277,6 +281,14 @@ function hasChildTasks(taskId: string): boolean {
|
||||
return allBoardTasks.value.some(task => task.parentTaskId === taskId)
|
||||
}
|
||||
|
||||
function delegationBadge(task: BoardTask): string | null {
|
||||
if (task.childTaskCount && task.openChildTaskCount) return `${task.openChildTaskCount}/${task.childTaskCount} aktiv`
|
||||
if (task.childTaskCount) return `${task.childTaskCount} Child-Tasks`
|
||||
if (task.parentTaskId) return 'Child-Task'
|
||||
if (task.isAgentTask || task.hasVisibleDelegation) return 'delegiert'
|
||||
return null
|
||||
}
|
||||
|
||||
function assigneeLabel(assignedTo: string | null | undefined): string {
|
||||
return expectedFromLabel(assignedTo)
|
||||
}
|
||||
@@ -382,12 +394,14 @@ watch(showDetailPanel, (open) => {
|
||||
})
|
||||
|
||||
/* ── Lifecycle ────────────────────────────────────── */
|
||||
let agentOverviewInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
taskStore.startBoardPolling()
|
||||
taskStore.fetchAgentOverview()
|
||||
liveSyncStore.connect()
|
||||
window.addEventListener('keydown', onGlobalKeydown)
|
||||
// Refresh agent overview on the same interval
|
||||
setInterval(() => taskStore.fetchAgentOverview(), 30000)
|
||||
agentOverviewInterval = setInterval(() => taskStore.fetchAgentOverview(), 30000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -396,6 +410,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
taskStore.stopBoardPolling()
|
||||
liveSyncStore.disconnect()
|
||||
if (agentOverviewInterval) clearInterval(agentOverviewInterval)
|
||||
window.removeEventListener('keydown', onGlobalKeydown)
|
||||
})
|
||||
</script>
|
||||
@@ -406,6 +422,10 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<h1><span class="grad-text">Aufgaben</span></h1>
|
||||
<p class="board-subtitle">Task Board — Übersicht aller Arbeitspakete</p>
|
||||
<div class="board-live-row">
|
||||
<span class="live-pill" :class="liveModeClass">{{ liveModeLabel }}</span>
|
||||
<span v-if="liveSyncStore.lastEventAt" class="live-meta">Letztes Event {{ relativeTime(liveSyncStore.lastEventAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="board-header-actions">
|
||||
<button
|
||||
@@ -546,6 +566,9 @@ onUnmounted(() => {
|
||||
<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"
|
||||
@@ -595,6 +618,9 @@ onUnmounted(() => {
|
||||
<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"
|
||||
@@ -644,6 +670,9 @@ onUnmounted(() => {
|
||||
<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"
|
||||
@@ -737,6 +766,9 @@ onUnmounted(() => {
|
||||
<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"
|
||||
@@ -837,6 +869,7 @@ onUnmounted(() => {
|
||||
<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 v-if="delegationBadge(selectedTask)" class="meta-expected">↳ {{ delegationBadge(selectedTask) }}</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>
|
||||
|
||||
@@ -169,6 +169,13 @@ function progressHint(taskLike: Pick<TaskDto, 'id' | 'lastActivityMessage' | 'ex
|
||||
return taskLike.lastActivityMessage?.trim() || childStatusSummary(taskLike.id) || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
|
||||
}
|
||||
|
||||
function delegationSummary(taskLike: TaskDto): string | null {
|
||||
if (taskLike.parentTaskId) return 'Sichtbare Child-Delegation'
|
||||
if (children.value.length) return `${children.value.length} sichtbare Child-Tasks`
|
||||
if (taskLike.isAgentTask) return 'Delegation im Board sichtbar'
|
||||
return null
|
||||
}
|
||||
|
||||
/* ── API calls ───────────────────────────────── */
|
||||
async function loadTask() {
|
||||
loading.value = true
|
||||
@@ -380,6 +387,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
<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>
|
||||
<span v-if="delegationSummary(task)" class="meta-chip">{{ delegationSummary(task) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -408,8 +416,9 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="task.isAgentTask || childStatusSummary(task.id)" class="progress-banner">
|
||||
<div v-if="task.isAgentTask || childStatusSummary(task.id) || delegationSummary(task)" class="progress-banner">
|
||||
<strong>Letzter Fortschritt:</strong> {{ progressHint(task) }}
|
||||
<span v-if="delegationSummary(task)" class="delegation-inline">· {{ delegationSummary(task) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
@@ -577,6 +586,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
<dl class="info-list">
|
||||
<div><dt>ID</dt><dd>#{{ task.id.slice(0, 8) }}</dd></div>
|
||||
<div><dt>Quelle</dt><dd>{{ task.source || '—' }}</dd></div>
|
||||
<div v-if="delegationSummary(task)"><dt>Delegation</dt><dd>{{ delegationSummary(task) }}</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>
|
||||
|
||||
Reference in New Issue
Block a user