feat(M13 T-519b): SSE worker_heartbeat + NavBar stand-by badge

Aanvulling op scrum4me-mcp PR #25 (worker_heartbeat MCP-tool).

- app/api/realtime/solo/route.ts: WorkerHeartbeatPayload type +
  isWorkerHeartbeatPayload guard + shouldEmit-routing op user_id.
- stores/solo-store.ts: workerQuotaPct + workerQuotaCheckAt state +
  setWorkerQuota action. Reset bij decrementWorkers naar 0.
- lib/realtime/use-solo-realtime.ts: handle worker_heartbeat-event,
  roep setWorkerQuota.
- components/solo/nav-status-indicators.tsx: stand-by badge wanneer
  workerQuotaPct < minQuotaPct + tooltip met drempel.
- components/shared/nav-bar.tsx + app/(app)/layout.tsx: minQuotaPct
  prop plumbing van User.min_quota_pct naar NavStatusIndicators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-06 04:26:56 +02:00
parent 555ed8fe89
commit 35835c693c
6 changed files with 88 additions and 7 deletions

View file

@ -16,7 +16,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
const [user, userRoles, accessibleProducts] = await Promise.all([
prisma.user.findUnique({
where: { id: session.userId },
select: { username: true, email: true, active_product_id: true },
select: { username: true, email: true, active_product_id: true, min_quota_pct: true },
}),
prisma.userRole.findMany({
where: { user_id: session.userId },
@ -72,6 +72,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
activeProduct={activeProduct}
products={accessibleProducts}
hasActiveSprint={hasActiveSprint}
minQuotaPct={user.min_quota_pct}
/>
<MinWidthBanner />
<main id="main-content" className="flex-1 flex flex-col overflow-y-auto min-h-0">

View file

@ -64,7 +64,17 @@ type WorkerPayload = {
product_id?: string
}
type NotifyPayload = EntityPayload | JobPayload | WorkerPayload
// M13: per-iteration quota-rapport van de worker. Geen product-scope —
// elke heartbeat geldt voor alle producten waar deze user toegang toe heeft.
type WorkerHeartbeatPayload = {
type: 'worker_heartbeat'
user_id: string
token_id: string
last_quota_pct: number
last_quota_check_at: string
}
type NotifyPayload = EntityPayload | JobPayload | WorkerPayload | WorkerHeartbeatPayload
function isJobPayload(p: NotifyPayload): p is JobPayload {
return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status')
@ -74,6 +84,10 @@ function isWorkerPayload(p: NotifyPayload): p is WorkerPayload {
return 'type' in p && (p.type === 'worker_connected' || p.type === 'worker_disconnected')
}
function isWorkerHeartbeatPayload(p: NotifyPayload): p is WorkerHeartbeatPayload {
return 'type' in p && p.type === 'worker_heartbeat'
}
function shouldEmit(
payload: NotifyPayload,
productId: string,
@ -90,6 +104,10 @@ function shouldEmit(
return payload.user_id === userId
}
if (isWorkerHeartbeatPayload(payload)) {
return payload.user_id === userId
}
// M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier.
if (payload.entity === 'question') return false

View file

@ -30,6 +30,7 @@ interface NavBarProps {
activeProduct: { id: string; name: string } | null
products: { id: string; name: string }[]
hasActiveSprint: boolean
minQuotaPct: number
}
export function NavBar({
@ -41,6 +42,7 @@ export function NavBar({
activeProduct,
products,
hasActiveSprint,
minQuotaPct,
}: NavBarProps) {
const pathname = usePathname()
const router = useRouter()
@ -188,7 +190,7 @@ export function NavBar({
{/* Rechts: solo-status + notifications + account-menu */}
<div className="flex items-center gap-2 flex-1 justify-end">
<SoloNavStatusIndicators hasActiveProduct={!!activeProduct} />
<SoloNavStatusIndicators hasActiveProduct={!!activeProduct} minQuotaPct={minQuotaPct} />
<NotificationsBell currentUserId={userId} isDemo={isDemo} />
<UserMenu userId={userId} username={username} email={email} roles={roles} />
</div>

View file

@ -40,13 +40,28 @@ function RealtimeIndicator({
)
}
export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct: boolean }) {
export function SoloNavStatusIndicators({
hasActiveProduct,
minQuotaPct,
}: {
hasActiveProduct: boolean
minQuotaPct: number
}) {
const realtimeStatus = useSoloStore((s) => s.realtimeStatus)
const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator)
const connectedWorkers = useSoloStore((s) => s.connectedWorkers)
const workerQuotaPct = useSoloStore((s) => s.workerQuotaPct)
if (!hasActiveProduct) return null
// M13: stand-by als alle workers low quota hebben (workerQuotaPct geldt
// voor de laatste-rapporterende worker; bij N>1 workers is dit een
// benadering — server-side aggregate is een v2-verbetering).
const isStandby =
connectedWorkers > 0 &&
workerQuotaPct !== null &&
workerQuotaPct < minQuotaPct
return (
<div className="flex items-center gap-3 px-2">
<RealtimeIndicator
@ -56,9 +71,28 @@ export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span className={cn(
'size-2 rounded-full',
connectedWorkers > 0 ? 'bg-status-done' : 'bg-muted-foreground/40'
isStandby
? 'bg-warning'
: connectedWorkers > 0
? 'bg-status-done'
: 'bg-muted-foreground/40'
)} />
{connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'}
{isStandby ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={<span>Stand-by ({workerQuotaPct}%)</span>}
/>
<TooltipContent>
Worker wacht tot Anthropic-quota stijgt boven {minQuotaPct}%
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : connectedWorkers > 0 ? (
'Agent verbonden'
) : (
'Geen agent'
)}
</div>
</div>
)

View file

@ -40,6 +40,7 @@ export function useSoloRealtime(productId: string | null) {
const setWorkers = useSoloStore.getState().setWorkers
const incrementWorkers = useSoloStore.getState().incrementWorkers
const decrementWorkers = useSoloStore.getState().decrementWorkers
const setWorkerQuota = useSoloStore.getState().setWorkerQuota
if (!productId) {
// Geen actief product (gebruiker zit niet op /solo) — stream uit
@ -119,6 +120,15 @@ export function useSoloRealtime(productId: string | null) {
}
if (raw.type === 'worker_connected') { incrementWorkers(); return }
if (raw.type === 'worker_disconnected') { decrementWorkers(); return }
if (raw.type === 'worker_heartbeat') {
const hb = raw as {
type: 'worker_heartbeat'
last_quota_pct: number
last_quota_check_at: string
}
setWorkerQuota(hb.last_quota_pct, hb.last_quota_check_at)
return
}
return
}
const payload = raw as RealtimeEvent

View file

@ -64,6 +64,11 @@ interface SoloStore {
claudeJobsByTaskId: Record<string, JobState>
connectedWorkers: number
// M13: laatste quota-rapport van een actieve worker. null = geen
// worker actief of nog geen heartbeat met quota ontvangen.
workerQuotaPct: number | null
workerQuotaCheckAt: string | null
initTasks: (tasks: SoloTask[]) => void
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
rollback: (taskId: string, prevStatus: TaskStatus) => void
@ -82,6 +87,7 @@ interface SoloStore {
setWorkers: (count: number) => void
incrementWorkers: () => void
decrementWorkers: () => void
setWorkerQuota: (pct: number, checkAt: string) => void
handleRealtimeEvent: (event: RealtimeEvent) => void
}
@ -93,6 +99,8 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
showConnectingIndicator: false,
claudeJobsByTaskId: {},
connectedWorkers: 0,
workerQuotaPct: null,
workerQuotaCheckAt: null,
initTasks: (tasks) =>
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
@ -145,7 +153,15 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }),
incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })),
decrementWorkers: () => set(s => ({ connectedWorkers: Math.max(0, s.connectedWorkers - 1) })),
decrementWorkers: () =>
set((s) => ({
connectedWorkers: Math.max(0, s.connectedWorkers - 1),
// Reset quota-state als alle workers weg zijn — pct van een vertrokken
// worker is niet meer actueel.
workerQuotaPct: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaPct,
workerQuotaCheckAt: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaCheckAt,
})),
setWorkerQuota: (pct, checkAt) => set({ workerQuotaPct: pct, workerQuotaCheckAt: checkAt }),
handleJobEvent: (event) => {
const { job_id, task_id } = event