diff --git a/frontend/src/stores/liveSync.ts b/frontend/src/stores/liveSync.ts new file mode 100644 index 0000000..0cbedfd --- /dev/null +++ b/frontend/src/stores/liveSync.ts @@ -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 | 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) + }, + }, +}) diff --git a/frontend/src/views/Dashboard/FlowBoard.vue b/frontend/src/views/Dashboard/FlowBoard.vue index e7b6155..cb02c03 100644 --- a/frontend/src/views/Dashboard/FlowBoard.vue +++ b/frontend/src/views/Dashboard/FlowBoard.vue @@ -18,7 +18,7 @@ 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 { useLiveSyncStore } from '../../stores/liveSync' import AlertBar from '../../components/dashboard/v2/AlertBar.vue' import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue' import IrisChat from '../../components/dashboard/v2/IrisChat.vue' diff --git a/frontend/src/views/NotificationsView.vue b/frontend/src/views/NotificationsView.vue index a6ca6c7..950d31c 100644 --- a/frontend/src/views/NotificationsView.vue +++ b/frontend/src/views/NotificationsView.vue @@ -2,7 +2,7 @@ import { onMounted, onUnmounted, computed } from 'vue' import { useRouter } from 'vue-router' import { useNotificationStore } from '../stores/notifications' -import { useLiveSyncStore } from '../stores/live-sync' +import { useLiveSyncStore } from '../stores/liveSync' import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue' const store = useNotificationStore() diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue index c6fb935..0f54cc8 100644 --- a/frontend/src/views/TaskBoardView.vue +++ b/frontend/src/views/TaskBoardView.vue @@ -17,7 +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' +import { useLiveSyncStore } from '../stores/liveSync' type BoardTask = ReturnType[number]