From 499c573544ffe23456895e3701eaf425db15fb64 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Fri, 13 Feb 2026 13:46:58 +0800 Subject: [PATCH] feat(desktop): add subagent dashboard with real-time status tracking Adds a status bar and slide-out dashboard panel to the chat view that shows subagent progress in real-time. Polls at 2s when active, 10s when idle. Completed runs auto-hide after 5 minutes. Includes dismiss button and 30s auto-dismiss for the status bar. Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/electron-env.d.ts | 17 +++ apps/desktop/src/main/ipc/index.ts | 3 + apps/desktop/src/main/ipc/subagents.ts | 63 +++++++++ apps/desktop/src/preload/index.ts | 20 +++ .../renderer/src/components/local-chat.tsx | 21 +++ .../src/components/subagent-dashboard.tsx | 122 ++++++++++++++++++ .../src/components/subagent-status-bar.tsx | 77 +++++++++++ .../src/hooks/use-subagent-polling.ts | 33 +++++ .../src/renderer/src/stores/subagents.ts | 29 +++++ packages/core/src/agent/index.ts | 10 ++ packages/ui/src/components/chat-view.tsx | 5 + 11 files changed, 400 insertions(+) create mode 100644 apps/desktop/src/main/ipc/subagents.ts create mode 100644 apps/desktop/src/renderer/src/components/subagent-dashboard.tsx create mode 100644 apps/desktop/src/renderer/src/components/subagent-status-bar.tsx create mode 100644 apps/desktop/src/renderer/src/hooks/use-subagent-polling.ts create mode 100644 apps/desktop/src/renderer/src/stores/subagents.ts diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts index 94137db5..64552b79 100644 --- a/apps/desktop/src/main/electron-env.d.ts +++ b/apps/desktop/src/main/electron-env.d.ts @@ -161,6 +161,20 @@ interface InboundMessageEvent { timestamp: number } +interface SubagentRunInfo { + runId: string + label: string | undefined + task: string + status: 'queued' | 'running' | 'ok' | 'error' | 'timeout' | 'unknown' + groupId: string | undefined + groupLabel: string | undefined + startedAt: number | undefined + endedAt: number | undefined + createdAt: number + findings: string | undefined + error: string | undefined +} + interface ElectronAPI { app: { getFlags: () => Promise<{ forceOnboarding: boolean }> @@ -236,6 +250,9 @@ interface ElectronAPI { stop: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> start: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> } + subagents: { + list: (requesterSessionId: string) => Promise + } cron: { list: () => Promise toggle: (jobId: string) => Promise<{ ok: boolean }> diff --git a/apps/desktop/src/main/ipc/index.ts b/apps/desktop/src/main/ipc/index.ts index 2080e15f..d599e343 100644 --- a/apps/desktop/src/main/ipc/index.ts +++ b/apps/desktop/src/main/ipc/index.ts @@ -10,6 +10,7 @@ export { registerChannelsIpcHandlers } from './channels.js' export { registerCronIpcHandlers } from './cron.js' export { registerHeartbeatIpcHandlers } from './heartbeat.js' export { registerAppStateIpcHandlers } from './app-state.js' +export { registerSubagentsIpcHandlers } from './subagents.js' import { registerAgentIpcHandlers, cleanupAgent } from './agent.js' import { registerSkillsIpcHandlers } from './skills.js' @@ -20,6 +21,7 @@ import { registerChannelsIpcHandlers } from './channels.js' import { registerCronIpcHandlers } from './cron.js' import { registerHeartbeatIpcHandlers } from './heartbeat.js' import { registerAppStateIpcHandlers } from './app-state.js' +import { registerSubagentsIpcHandlers } from './subagents.js' /** * Register all IPC handlers. @@ -35,6 +37,7 @@ export function registerAllIpcHandlers(): void { registerCronIpcHandlers() registerHeartbeatIpcHandlers() registerAppStateIpcHandlers() + registerSubagentsIpcHandlers() } /** diff --git a/apps/desktop/src/main/ipc/subagents.ts b/apps/desktop/src/main/ipc/subagents.ts new file mode 100644 index 00000000..ea901a91 --- /dev/null +++ b/apps/desktop/src/main/ipc/subagents.ts @@ -0,0 +1,63 @@ +/** + * Subagent IPC handlers for Electron main process. + * + * Exposes subagent registry data to the renderer process + * for the Subagent Dashboard UI. + */ +import { ipcMain } from 'electron' +import { listSubagentRuns, getSubagentGroup } from '@multica/core' +import type { SubagentRunRecord } from '@multica/core' + +/** Serializable DTO for renderer consumption */ +export interface SubagentRunInfo { + runId: string + label: string | undefined + task: string + status: 'queued' | 'running' | 'ok' | 'error' | 'timeout' | 'unknown' + groupId: string | undefined + groupLabel: string | undefined + startedAt: number | undefined + endedAt: number | undefined + createdAt: number + findings: string | undefined + error: string | undefined +} + +function deriveStatus(record: SubagentRunRecord): SubagentRunInfo['status'] { + if (!record.startedAt) return 'queued' + if (!record.endedAt) return 'running' + return record.outcome?.status ?? 'unknown' +} + +function toDTO(record: SubagentRunRecord): SubagentRunInfo { + const group = record.groupId ? getSubagentGroup(record.groupId) : undefined + return { + runId: record.runId, + label: record.label, + task: record.task, + status: deriveStatus(record), + groupId: record.groupId, + groupLabel: group?.label, + startedAt: record.startedAt, + endedAt: record.endedAt, + createdAt: record.createdAt, + findings: record.findings ? record.findings.slice(0, 500) : undefined, + error: record.outcome?.error, + } +} + +/** Hide completed runs after 5 minutes */ +const COMPLETED_RETENTION_MS = 5 * 60 * 1000 + +/** + * Register all Subagent-related IPC handlers. + */ +export function registerSubagentsIpcHandlers(): void { + ipcMain.handle('subagents:list', async (_event, requesterSessionId: string) => { + const now = Date.now() + const runs = listSubagentRuns(requesterSessionId) + return runs + .filter((r) => !r.endedAt || now - r.endedAt < COMPLETED_RETENTION_MS) + .map(toDTO) + }) +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 6c9dba62..800e91bf 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -105,6 +105,20 @@ export interface LocalChatApproval { expiresAtMs: number } +export interface SubagentRunInfo { + runId: string + label: string | undefined + task: string + status: 'queued' | 'running' | 'ok' | 'error' | 'timeout' | 'unknown' + groupId: string | undefined + groupLabel: string | undefined + startedAt: number | undefined + endedAt: number | undefined + createdAt: number + findings: string | undefined + error: string | undefined +} + // ============================================================================ // Expose typed API to Renderer process // ============================================================================ @@ -251,6 +265,12 @@ const electronAPI = { ipcRenderer.invoke('channels:start', channelId, accountId), }, + // Subagent dashboard + subagents: { + list: (requesterSessionId: string): Promise => + ipcRenderer.invoke('subagents:list', requesterSessionId), + }, + // Cron jobs management cron: { list: () => ipcRenderer.invoke('cron:list'), diff --git a/apps/desktop/src/renderer/src/components/local-chat.tsx b/apps/desktop/src/renderer/src/components/local-chat.tsx index b62c9e6b..39c6c0b3 100644 --- a/apps/desktop/src/renderer/src/components/local-chat.tsx +++ b/apps/desktop/src/renderer/src/components/local-chat.tsx @@ -3,9 +3,13 @@ import { useNavigate } from 'react-router-dom' import { Loading } from '@multica/ui/components/ui/loading' import { ChatView } from '@multica/ui/components/chat-view' import { useLocalChat } from '../hooks/use-local-chat' +import { useSubagentPolling } from '../hooks/use-subagent-polling' +import { useSubagentsStore } from '../stores/subagents' import { useProviderStore } from '../stores/provider' import { ApiKeyDialog } from './api-key-dialog' import { OAuthDialog } from './oauth-dialog' +import { SubagentStatusBar } from './subagent-status-bar' +import { SubagentDashboard } from './subagent-dashboard' interface LocalChatProps { initialPrompt?: string @@ -33,6 +37,11 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProviderStore() + // Subagent polling + dashboard + useSubagentPolling(agentId) + const subagentRuns = useSubagentsStore((s) => s.runs) + const [dashboardOpen, setDashboardOpen] = useState(false) + // Provider config dialog state const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) const [oauthDialogOpen, setOauthDialogOpen] = useState(false) @@ -116,6 +125,12 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { loadMore={loadMore} resolveApproval={resolveApproval} errorAction={errorAction} + bottomSlot={ + setDashboardOpen(true)} + /> + } /> {currentMeta && currentMeta.authMethod === 'api-key' && ( @@ -138,6 +153,12 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { onSuccess={handleProviderConfigSuccess} /> )} + + ) } diff --git a/apps/desktop/src/renderer/src/components/subagent-dashboard.tsx b/apps/desktop/src/renderer/src/components/subagent-dashboard.tsx new file mode 100644 index 00000000..bac29773 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/subagent-dashboard.tsx @@ -0,0 +1,122 @@ +import { useState, useEffect } from 'react' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from '@multica/ui/components/ui/sheet' +import { Badge } from '@multica/ui/components/ui/badge' + +interface SubagentDashboardProps { + open: boolean + onOpenChange: (open: boolean) => void + runs: SubagentRunInfo[] +} + +const STATUS_CONFIG: Record = { + running: { label: 'Running', variant: 'default' }, + queued: { label: 'Queued', variant: 'secondary' }, + ok: { label: 'Completed', variant: 'outline' }, + error: { label: 'Error', variant: 'destructive' }, + timeout: { label: 'Timeout', variant: 'destructive' }, + unknown: { label: 'Unknown', variant: 'secondary' }, +} + +function formatElapsed(startMs: number, endMs?: number): string { + const elapsed = (endMs ?? Date.now()) - startMs + const seconds = Math.floor(elapsed / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainSec = seconds % 60 + if (minutes < 60) return `${minutes}m ${remainSec}s` + const hours = Math.floor(minutes / 60) + const remainMin = minutes % 60 + return `${hours}h ${remainMin}m` +} + +function RunCard({ run }: { run: SubagentRunInfo }) { + const config = STATUS_CONFIG[run.status] + const isActive = run.status === 'running' || run.status === 'queued' + const [, setTick] = useState(0) + + // Tick every 1s for running agents to update elapsed time + useEffect(() => { + if (!isActive) return + const timer = setInterval(() => setTick((t) => t + 1), 1000) + return () => clearInterval(timer) + }, [isActive]) + + return ( +
+
+
+

+ {run.label || run.task.slice(0, 80)} +

+ {run.label && ( +

+ {run.task.slice(0, 120)} +

+ )} +
+ + {config.label} + +
+ +
+ {run.startedAt && ( + {formatElapsed(run.startedAt, run.endedAt)} + )} + {run.groupLabel && ( + + {run.groupLabel} + + )} +
+ + {run.error && ( +

+ {run.error} +

+ )} + + {run.findings && !run.error && ( +

+ {run.findings.slice(0, 200)} +

+ )} +
+ ) +} + +export function SubagentDashboard({ open, onOpenChange, runs }: SubagentDashboardProps) { + // Sort: active first (running, queued), then by createdAt desc + const sorted = [...runs].sort((a, b) => { + const aActive = a.status === 'running' || a.status === 'queued' ? 0 : 1 + const bActive = b.status === 'running' || b.status === 'queued' ? 0 : 1 + if (aActive !== bActive) return aActive - bActive + return b.createdAt - a.createdAt + }) + + return ( + + + + Subagents ({runs.length}) + Child agents spawned by the current session + +
+ {sorted.length === 0 ? ( +

+ No subagents yet +

+ ) : ( + sorted.map((run) => ) + )} +
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/subagent-status-bar.tsx b/apps/desktop/src/renderer/src/components/subagent-status-bar.tsx new file mode 100644 index 00000000..60e4125f --- /dev/null +++ b/apps/desktop/src/renderer/src/components/subagent-status-bar.tsx @@ -0,0 +1,77 @@ +import { useState, useEffect, useRef } from 'react' + +/** Auto-dismiss delay after all runs complete (ms) */ +const DISMISS_DELAY_MS = 30_000 + +interface SubagentStatusBarProps { + runs: SubagentRunInfo[] + onViewClick: () => void +} + +export function SubagentStatusBar({ runs, onViewClick }: SubagentStatusBarProps) { + const [dismissed, setDismissed] = useState(false) + const prevHadActiveRef = useRef(false) + + const running = runs.filter((r) => r.status === 'running' || r.status === 'queued').length + const completed = runs.filter((r) => r.status !== 'running' && r.status !== 'queued').length + const hasActive = running > 0 + + // Auto-dismiss after all runs complete + useEffect(() => { + if (hasActive) { + // Reset dismissed state when new active runs appear + prevHadActiveRef.current = true + setDismissed(false) + return + } + + // Only auto-dismiss if we previously had active runs (transition to all-complete) + if (!prevHadActiveRef.current || runs.length === 0) return + + const timer = setTimeout(() => setDismissed(true), DISMISS_DELAY_MS) + return () => clearTimeout(timer) + }, [hasActive, runs.length]) + + if (runs.length === 0 || dismissed) return null + + let statusText: string + if (running > 0 && completed > 0) { + statusText = `${running} running, ${completed} completed` + } else if (running > 0) { + statusText = `${running} subagent${running > 1 ? 's' : ''} running` + } else { + statusText = `${completed} completed` + } + + return ( +
+
+
+ {running > 0 && ( + + + + + )} + {statusText} +
+
+ + {!hasActive && ( + + )} +
+
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/hooks/use-subagent-polling.ts b/apps/desktop/src/renderer/src/hooks/use-subagent-polling.ts new file mode 100644 index 00000000..78f73d77 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-subagent-polling.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react' +import { useSubagentsStore, selectHasActiveRuns } from '../stores/subagents' + +const ACTIVE_INTERVAL_MS = 2_000 +const IDLE_INTERVAL_MS = 10_000 + +/** + * Polls for subagent runs at an adaptive interval. + * 2s when there are active (running/queued) runs, 10s otherwise. + */ +export function useSubagentPolling(agentId: string | null): void { + const fetch = useSubagentsStore((s) => s.fetch) + const runs = useSubagentsStore((s) => s.runs) + const hasActive = selectHasActiveRuns(runs) + const intervalRef = useRef | null>(null) + + useEffect(() => { + if (!agentId) return + + // Fetch immediately + fetch(agentId) + + const ms = hasActive ? ACTIVE_INTERVAL_MS : IDLE_INTERVAL_MS + intervalRef.current = setInterval(() => fetch(agentId), ms) + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [agentId, hasActive, fetch]) +} diff --git a/apps/desktop/src/renderer/src/stores/subagents.ts b/apps/desktop/src/renderer/src/stores/subagents.ts new file mode 100644 index 00000000..249ed321 --- /dev/null +++ b/apps/desktop/src/renderer/src/stores/subagents.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand' + +interface SubagentsStore { + runs: SubagentRunInfo[] + fetch: (requesterSessionId: string) => Promise +} + +export const useSubagentsStore = create()((set) => ({ + runs: [], + + fetch: async (requesterSessionId: string) => { + try { + const result = await window.electronAPI.subagents.list(requesterSessionId) + if (Array.isArray(result)) { + set({ runs: result }) + } + } catch (err) { + console.error('[SubagentsStore] Failed to fetch:', err) + } + }, +})) + +export function selectRunningCount(runs: SubagentRunInfo[]): number { + return runs.filter((r) => r.status === 'running' || r.status === 'queued').length +} + +export function selectHasActiveRuns(runs: SubagentRunInfo[]): boolean { + return runs.some((r) => r.status === 'running' || r.status === 'queued') +} diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index acd36784..13325fe0 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -13,6 +13,16 @@ export * from "./tools.js"; export * from "./tools/policy.js"; export * from "./tools/groups.js"; export * from "./extract-text.js"; +export { + listSubagentRuns, + getSubagentRun, + getSubagentGroup, +} from "./subagent/registry.js"; +export type { + SubagentRunRecord, + SubagentRunOutcome, + SubagentGroup, +} from "./subagent/types.js"; export { readClaudeCliCredentials, readCodexCliCredentials, diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index 37abaff6..79d95ef9 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -44,6 +44,8 @@ export interface ChatViewProps { errorAction?: { label: string; onClick: () => void }; /** Initial prompt to pre-fill the input (e.g., from onboarding) */ initialPrompt?: string; + /** Optional slot rendered between the error bar and the input footer */ + bottomSlot?: React.ReactNode; } export function ChatView({ @@ -62,6 +64,7 @@ export function ChatView({ onDisconnect, errorAction, initialPrompt, + bottomSlot, }: ChatViewProps) { const mainRef = useRef(null); const sentinelRef = useRef(null); @@ -259,6 +262,8 @@ export function ChatView({ )} + {bottomSlot} +