refactor(desktop): drop legacy subagent dashboard wiring
This commit is contained in:
parent
909efb5dab
commit
db0f8b3f7b
9 changed files with 7 additions and 392 deletions
21
apps/desktop/src/main/electron-env.d.ts
vendored
21
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -162,21 +162,7 @@ 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 {
|
||||
interface ElectronAPI {
|
||||
app: {
|
||||
getFlags: () => Promise<{ forceOnboarding: boolean }>
|
||||
}
|
||||
|
|
@ -251,10 +237,7 @@ 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<SubagentRunInfo[]>
|
||||
}
|
||||
cron: {
|
||||
cron: {
|
||||
list: () => Promise<unknown[]>
|
||||
toggle: (jobId: string) => Promise<{ ok: boolean }>
|
||||
remove: (jobId: string) => Promise<{ ok: boolean }>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export { registerCronIpcHandlers } from './cron.js'
|
|||
export { registerHeartbeatIpcHandlers } from './heartbeat.js'
|
||||
export { registerAppStateIpcHandlers } from './app-state.js'
|
||||
export { registerAuthHandlers, setMainWindow as setAuthMainWindow, handleAuthDeepLink } from './auth.js'
|
||||
export { registerSubagentsIpcHandlers } from './subagents.js'
|
||||
|
||||
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
|
||||
import { registerAuthHandlers } from './auth.js'
|
||||
|
|
@ -23,7 +22,6 @@ 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.
|
||||
|
|
@ -40,7 +38,6 @@ export function registerAllIpcHandlers(): void {
|
|||
registerHeartbeatIpcHandlers()
|
||||
registerAppStateIpcHandlers()
|
||||
registerAuthHandlers()
|
||||
registerSubagentsIpcHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
/**
|
||||
* 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -105,23 +105,9 @@ 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
|
||||
// ============================================================================
|
||||
// ============================================================================
|
||||
// Expose typed API to Renderer process
|
||||
// ============================================================================
|
||||
|
||||
const electronAPI = {
|
||||
// App-level
|
||||
|
|
@ -291,14 +277,8 @@ const electronAPI = {
|
|||
ipcRenderer.invoke('channels:start', channelId, accountId),
|
||||
},
|
||||
|
||||
// Subagent dashboard
|
||||
subagents: {
|
||||
list: (requesterSessionId: string): Promise<SubagentRunInfo[]> =>
|
||||
ipcRenderer.invoke('subagents:list', requesterSessionId),
|
||||
},
|
||||
|
||||
// Cron jobs management
|
||||
cron: {
|
||||
// Cron jobs management
|
||||
cron: {
|
||||
list: () => ipcRenderer.invoke('cron:list'),
|
||||
toggle: (jobId: string) => ipcRenderer.invoke('cron:toggle', jobId),
|
||||
remove: (jobId: string) => ipcRenderer.invoke('cron:remove', jobId),
|
||||
|
|
|
|||
|
|
@ -3,13 +3,9 @@ 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
|
||||
|
|
@ -37,11 +33,6 @@ 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)
|
||||
|
|
@ -126,12 +117,6 @@ export function LocalChat({ initialPrompt }: LocalChatProps) {
|
|||
loadMore={loadMore}
|
||||
resolveApproval={resolveApproval}
|
||||
errorAction={errorAction}
|
||||
bottomSlot={
|
||||
<SubagentStatusBar
|
||||
runs={subagentRuns}
|
||||
onViewClick={() => setDashboardOpen(true)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{currentMeta && currentMeta.authMethod === 'api-key' && (
|
||||
|
|
@ -154,12 +139,6 @@ export function LocalChat({ initialPrompt }: LocalChatProps) {
|
|||
onSuccess={handleProviderConfigSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SubagentDashboard
|
||||
open={dashboardOpen}
|
||||
onOpenChange={setDashboardOpen}
|
||||
runs={subagentRuns}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
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<SubagentRunInfo['status'], { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
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 (
|
||||
<div className="rounded-lg border bg-card p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{run.label || run.task.slice(0, 80)}
|
||||
</p>
|
||||
{run.label && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{run.task.slice(0, 120)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={config.variant} className="shrink-0">
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{run.startedAt && (
|
||||
<span>{formatElapsed(run.startedAt, run.endedAt)}</span>
|
||||
)}
|
||||
{run.groupLabel && (
|
||||
<span className="bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||
{run.groupLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{run.error && (
|
||||
<p className="text-xs text-destructive bg-destructive/5 rounded px-2 py-1 font-mono">
|
||||
{run.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{run.findings && !run.error && (
|
||||
<p className="text-xs text-muted-foreground bg-muted/50 rounded px-2 py-1.5 font-mono whitespace-pre-wrap line-clamp-4">
|
||||
{run.findings.slice(0, 200)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Subagents ({runs.length})</SheetTitle>
|
||||
<SheetDescription>Child agents spawned by the current session</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-2">
|
||||
{sorted.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No subagents yet
|
||||
</p>
|
||||
) : (
|
||||
sorted.map((run) => <RunCard key={run.runId} run={run} />)
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
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 (
|
||||
<div className="container px-4">
|
||||
<div className="flex items-center justify-between h-8 px-3 rounded-lg bg-muted/50 border text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
{running > 0 && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
)}
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onViewClick}
|
||||
className="text-xs font-medium text-foreground/70 hover:text-foreground transition-colors"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{!hasActive && (
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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<ReturnType<typeof setInterval> | 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])
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { create } from 'zustand'
|
||||
|
||||
interface SubagentsStore {
|
||||
runs: SubagentRunInfo[]
|
||||
fetch: (requesterSessionId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSubagentsStore = create<SubagentsStore>()((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')
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue