mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat: display background task count in status bar (#421)
This commit is contained in:
+2
-1
@@ -179,7 +179,8 @@ async function main() {
|
||||
onWebappEvent: (event: SyncEvent) => syncEngine?.handleRealtimeEvent(event),
|
||||
onSessionAlive: (payload) => syncEngine?.handleSessionAlive(payload),
|
||||
onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload),
|
||||
onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload)
|
||||
onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload),
|
||||
onBackgroundTaskDelta: (sessionId, delta) => syncEngine?.handleBackgroundTaskDelta(sessionId, delta)
|
||||
})
|
||||
|
||||
syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager)
|
||||
|
||||
@@ -40,10 +40,11 @@ export type CliHandlersDeps = {
|
||||
onSessionEnd?: (payload: SessionEndPayload) => void
|
||||
onMachineAlive?: (payload: MachineAlivePayload) => void
|
||||
onWebappEvent?: (event: SyncEvent) => void
|
||||
onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void
|
||||
}
|
||||
|
||||
export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlersDeps): void {
|
||||
const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent } = deps
|
||||
const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta } = deps
|
||||
const terminalNamespace = io.of('/terminal')
|
||||
const namespace = typeof socket.data.namespace === 'string' ? socket.data.namespace : null
|
||||
|
||||
@@ -102,7 +103,8 @@ export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlers
|
||||
emitAccessError,
|
||||
onSessionAlive,
|
||||
onSessionEnd,
|
||||
onWebappEvent
|
||||
onWebappEvent,
|
||||
onBackgroundTaskDelta
|
||||
})
|
||||
registerMachineHandlers(socket, {
|
||||
store,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Store, StoredSession } from '../../../store'
|
||||
import type { SyncEvent } from '../../../sync/syncEngine'
|
||||
import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos'
|
||||
import { extractTeamStateFromMessageContent, applyTeamStateDelta } from '../../../sync/teams'
|
||||
import { extractBackgroundTaskDelta } from '../../../sync/backgroundTasks'
|
||||
import type { CliSocketWithData } from '../../socketTypes'
|
||||
import type { AccessErrorReason, AccessResult } from './types'
|
||||
|
||||
@@ -57,10 +58,11 @@ export type SessionHandlersDeps = {
|
||||
onSessionAlive?: (payload: SessionAlivePayload) => void
|
||||
onSessionEnd?: (payload: SessionEndPayload) => void
|
||||
onWebappEvent?: (event: SyncEvent) => void
|
||||
onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void
|
||||
}
|
||||
|
||||
export function registerSessionHandlers(socket: CliSocketWithData, deps: SessionHandlersDeps): void {
|
||||
const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent } = deps
|
||||
const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent, onBackgroundTaskDelta } = deps
|
||||
|
||||
socket.on('message', (data: unknown) => {
|
||||
const parsed = messageSchema.safeParse(data)
|
||||
@@ -109,6 +111,11 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session
|
||||
}
|
||||
}
|
||||
|
||||
const bgDelta = extractBackgroundTaskDelta(content)
|
||||
if (bgDelta) {
|
||||
onBackgroundTaskDelta?.(sid, bgDelta)
|
||||
}
|
||||
|
||||
const update = {
|
||||
id: randomUUID(),
|
||||
seq: msg.seq,
|
||||
|
||||
@@ -39,6 +39,7 @@ export type SocketServerDeps = {
|
||||
onSessionAlive?: (payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }) => void
|
||||
onSessionEnd?: (payload: { sid: string; time: number }) => void
|
||||
onMachineAlive?: (payload: { machineId: string; time: number }) => void
|
||||
onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void
|
||||
}
|
||||
|
||||
export function createSocketServer(deps: SocketServerDeps): {
|
||||
@@ -113,7 +114,8 @@ export function createSocketServer(deps: SocketServerDeps): {
|
||||
onSessionAlive: deps.onSessionAlive,
|
||||
onSessionEnd: deps.onSessionEnd,
|
||||
onMachineAlive: deps.onMachineAlive,
|
||||
onWebappEvent: deps.onWebappEvent
|
||||
onWebappEvent: deps.onWebappEvent,
|
||||
onBackgroundTaskDelta: deps.onBackgroundTaskDelta
|
||||
}))
|
||||
|
||||
terminalNs.use(async (socket, next) => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { isObject } from '@hapi/protocol'
|
||||
import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages'
|
||||
|
||||
/**
|
||||
* Extract background task start/completion signals from a message.
|
||||
*
|
||||
* Uses role-aware parsing to avoid false positives:
|
||||
* - Started: agent-role output with tool_result containing
|
||||
* "Command running in background with ID:"
|
||||
* - Completed: agent-role output wrapping a user-type message (system-injected)
|
||||
* starting with "<task-notification>"
|
||||
*
|
||||
* Both signals arrive as { role: 'agent', content: { type: 'output', data: {...} } }
|
||||
* because the CLI wraps all messages in agent envelopes.
|
||||
*/
|
||||
export function extractBackgroundTaskDelta(messageContent: unknown): { started: number; completed: number } | null {
|
||||
const record = unwrapRoleWrappedRecordEnvelope(messageContent)
|
||||
if (!record || record.role !== 'agent') return null
|
||||
if (!isObject(record.content) || record.content.type !== 'output') return null
|
||||
|
||||
const data = isObject(record.content.data) ? record.content.data : null
|
||||
if (!data) return null
|
||||
|
||||
const started = countTaskStarts(record.content)
|
||||
const completed = data.type === 'user' ? countTaskCompletions(data) : 0
|
||||
|
||||
if (started === 0 && completed === 0) return null
|
||||
return { started, completed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Count background task starts from tool_result blocks.
|
||||
*/
|
||||
function countTaskStarts(content: Record<string, unknown>): number {
|
||||
const data = isObject(content.data) ? content.data : null
|
||||
if (!data) return 0
|
||||
|
||||
// Direct tool_result
|
||||
if (data.type === 'tool_result') {
|
||||
return isBackgroundStartResult(data) ? 1 : 0
|
||||
}
|
||||
|
||||
// Assistant message with content array containing tool_result blocks
|
||||
if (data.type === 'assistant') {
|
||||
const message = isObject(data.message) ? data.message : null
|
||||
const modelContent = message?.content
|
||||
if (!Array.isArray(modelContent)) return 0
|
||||
|
||||
let count = 0
|
||||
for (const block of modelContent) {
|
||||
if (isObject(block) && block.type === 'tool_result' && isBackgroundStartResult(block)) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function isBackgroundStartResult(block: Record<string, unknown>): boolean {
|
||||
const text = typeof block.content === 'string'
|
||||
? block.content
|
||||
: Array.isArray(block.content)
|
||||
? block.content.map((c: unknown) => isObject(c) && typeof c.text === 'string' ? c.text : '').join('')
|
||||
: ''
|
||||
return text.includes('Command running in background with ID:')
|
||||
}
|
||||
|
||||
/**
|
||||
* Count task completions from system-injected user messages.
|
||||
*
|
||||
* These arrive as: { type: 'user', message: { content: '<task-notification>...' } }
|
||||
* inside the agent output envelope.
|
||||
*/
|
||||
function countTaskCompletions(data: Record<string, unknown>): number {
|
||||
// { type: 'user', message: { content: '<task-notification>...' } }
|
||||
if (isObject(data.message)) {
|
||||
const msg = data.message as Record<string, unknown>
|
||||
if (typeof msg.content === 'string' && msg.content.trimStart().startsWith('<task-notification>')) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// { type: 'user', uuid: '...', content: '<task-notification>...' }
|
||||
if (typeof data.content === 'string' && data.content.trimStart().startsWith('<task-notification>')) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { Store } from '../store'
|
||||
import { clampAliveTime } from './aliveTime'
|
||||
import { EventPublisher } from './eventPublisher'
|
||||
import { extractTodoWriteTodosFromMessageContent, TodosSchema } from './todos'
|
||||
import { extractBackgroundTaskDelta } from './backgroundTasks'
|
||||
|
||||
export class SessionCache {
|
||||
private readonly sessions: Map<string, Session> = new Map()
|
||||
@@ -131,6 +132,7 @@ export class SessionCache {
|
||||
agentStateVersion: stored.agentStateVersion,
|
||||
thinking: existing?.thinking ?? false,
|
||||
thinkingAt: existing?.thinkingAt ?? 0,
|
||||
backgroundTaskCount: existing?.backgroundTaskCount ?? 0,
|
||||
todos,
|
||||
teamState,
|
||||
model: stored.model,
|
||||
@@ -230,6 +232,22 @@ export class SessionCache {
|
||||
}
|
||||
}
|
||||
|
||||
applyBackgroundTaskDelta(sessionId: string, delta: { started: number; completed: number }): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
const prev = session.backgroundTaskCount ?? 0
|
||||
const next = Math.max(0, prev + delta.started - delta.completed)
|
||||
if (next === prev) return
|
||||
|
||||
session.backgroundTaskCount = next
|
||||
this.publisher.emit({
|
||||
type: 'session-updated',
|
||||
sessionId,
|
||||
data: { backgroundTaskCount: next }
|
||||
})
|
||||
}
|
||||
|
||||
handleSessionEnd(payload: { sid: string; time: number }): void {
|
||||
const t = clampAliveTime(payload.time) ?? Date.now()
|
||||
|
||||
@@ -243,8 +261,9 @@ export class SessionCache {
|
||||
session.active = false
|
||||
session.thinking = false
|
||||
session.thinkingAt = t
|
||||
session.backgroundTaskCount = 0
|
||||
|
||||
this.publisher.emit({ type: 'session-updated', sessionId: session.id, data: { active: false, thinking: false } })
|
||||
this.publisher.emit({ type: 'session-updated', sessionId: session.id, data: { active: false, thinking: false, backgroundTaskCount: 0 } })
|
||||
}
|
||||
|
||||
expireInactive(now: number = Date.now()): void {
|
||||
|
||||
@@ -198,6 +198,10 @@ export class SyncEngine {
|
||||
this.sessionCache.handleSessionEnd(payload)
|
||||
}
|
||||
|
||||
handleBackgroundTaskDelta(sessionId: string, delta: { started: number; completed: number }): void {
|
||||
this.sessionCache.applyBackgroundTaskDelta(sessionId, delta)
|
||||
}
|
||||
|
||||
handleMachineAlive(payload: { machineId: string; time: number }): void {
|
||||
this.machineCache.handleMachineAlive(payload)
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ export const SessionSchema = z.object({
|
||||
agentStateVersion: z.number(),
|
||||
thinking: z.boolean(),
|
||||
thinkingAt: z.number(),
|
||||
backgroundTaskCount: z.number().optional(),
|
||||
todos: TodosSchema.optional(),
|
||||
teamState: TeamStateSchema.optional(),
|
||||
model: z.string().nullable(),
|
||||
|
||||
@@ -48,6 +48,7 @@ export function HappyComposer(props: {
|
||||
allowSendWhenInactive?: boolean
|
||||
thinking?: boolean
|
||||
agentState?: AgentState | null
|
||||
backgroundTaskCount?: number
|
||||
contextSize?: number
|
||||
controlledByUser?: boolean
|
||||
agentFlavor?: string | null
|
||||
@@ -77,6 +78,7 @@ export function HappyComposer(props: {
|
||||
allowSendWhenInactive = false,
|
||||
thinking = false,
|
||||
agentState,
|
||||
backgroundTaskCount,
|
||||
contextSize,
|
||||
controlledByUser = false,
|
||||
agentFlavor,
|
||||
@@ -674,6 +676,7 @@ export function HappyComposer(props: {
|
||||
active={active}
|
||||
thinking={thinking}
|
||||
agentState={agentState}
|
||||
backgroundTaskCount={backgroundTaskCount}
|
||||
contextSize={contextSize}
|
||||
model={model}
|
||||
permissionMode={permissionMode}
|
||||
|
||||
@@ -42,6 +42,7 @@ function getConnectionStatus(
|
||||
thinking: boolean,
|
||||
agentState: AgentState | null | undefined,
|
||||
voiceStatus: ConversationStatus | undefined,
|
||||
backgroundTaskCount: number,
|
||||
t: (key: string) => string
|
||||
): { text: string; color: string; dotColor: string; isPulsing: boolean } {
|
||||
const hasPermissions = agentState?.requests && Object.keys(agentState.requests).length > 0
|
||||
@@ -84,6 +85,15 @@ function getConnectionStatus(
|
||||
}
|
||||
}
|
||||
|
||||
if (backgroundTaskCount > 0) {
|
||||
return {
|
||||
text: `${backgroundTaskCount} background task${backgroundTaskCount > 1 ? 's' : ''} running`,
|
||||
color: 'text-[#007AFF]',
|
||||
dotColor: 'bg-[#007AFF]',
|
||||
isPulsing: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: t('misc.online'),
|
||||
color: 'text-[#34C759]',
|
||||
@@ -110,6 +120,7 @@ export function StatusBar(props: {
|
||||
active: boolean
|
||||
thinking: boolean
|
||||
agentState: AgentState | null | undefined
|
||||
backgroundTaskCount?: number
|
||||
contextSize?: number
|
||||
model?: string | null
|
||||
permissionMode?: PermissionMode
|
||||
@@ -119,8 +130,8 @@ export function StatusBar(props: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const connectionStatus = useMemo(
|
||||
() => getConnectionStatus(props.active, props.thinking, props.agentState, props.voiceStatus, t),
|
||||
[props.active, props.thinking, props.agentState, props.voiceStatus, t]
|
||||
() => getConnectionStatus(props.active, props.thinking, props.agentState, props.voiceStatus, props.backgroundTaskCount ?? 0, t),
|
||||
[props.active, props.thinking, props.agentState, props.voiceStatus, props.backgroundTaskCount, t]
|
||||
)
|
||||
|
||||
const contextWarning = useMemo(
|
||||
|
||||
@@ -379,6 +379,7 @@ export function SessionChat(props: {
|
||||
allowSendWhenInactive
|
||||
thinking={props.session.thinking}
|
||||
agentState={props.session.agentState}
|
||||
backgroundTaskCount={props.session.backgroundTaskCount}
|
||||
contextSize={reduced.latestUsage?.contextSize}
|
||||
controlledByUser={controlledByUser}
|
||||
onCollaborationModeChange={
|
||||
|
||||
Reference in New Issue
Block a user