fix(live): speed up live status loading (#615)

This commit is contained in:
matevip 2026-08-25 04:12:19 -04:00
parent bc2b4613f7
commit ab5a0a651b
12 changed files with 429 additions and 27 deletions

View File

@ -65,14 +65,22 @@
</button>
</div>
<!-- Loading -->
<div v-if="isInitialLoading" class="cards-grid">
<div v-for="i in 3" :key="i" class="agent-card mc-surface-card agent-card-skeleton">
<el-skeleton :rows="2" animated />
<div v-if="initialLoadingCopy" class="live-loading-state" role="status" aria-live="polite">
<span v-if="initialLoadingStage !== 'failed'" class="live-loading-spinner" aria-hidden="true"></span>
<div class="live-loading-copy">
<strong>{{ t(initialLoadingCopy.title) }}</strong>
<span>{{ t(initialLoadingCopy.hint) }}</span>
</div>
<button v-if="initialLoadingStage === 'failed'" class="loading-retry" @click="retryInitialLoad">
{{ t('live.loading.retry') }}
</button>
</div>
<template v-else>
<div v-if="loadingState.stage === 'syncingTeams'" class="team-sync-note" role="status" aria-live="polite">
<span class="team-sync-dot" aria-hidden="true"></span>
<span>{{ t('live.loading.syncingTeams') }}</span>
</div>
<AgentRunGroups
:groups="teamProjection.groups"
:selected-run-id="selectedTeamRunId"
@ -213,6 +221,7 @@ import { useLiveSnapshot } from '@/composables/useLiveSnapshot'
import { mcConfirm } from '@/components/common/useConfirm'
import { liveApi, goalApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard, type Goal } from '@/api'
import { buildTeamRunRoute } from '@/components/team-run/teamRunPresentation'
import { resolveLivePanelLoading, type LivePanelLoadingStage } from '@/utils/livePanelLoading'
const { t } = useI18n()
const route = useRoute()
@ -243,12 +252,36 @@ const routeHydrator = createAgentsLiveRouteHydrator({
})
const selectedTeamRunId = computed(() => selection.value.selectedRunId)
const selectedTeamTaskId = computed(() => selection.value.selectedTaskId)
const isInitialLoading = liveSnapshot.loading
const loadingElapsedMs = ref(0)
const hasRuntimeActivity = computed(() => (
(snapshot.value?.runs.length ?? 0) > 0 || (snapshot.value?.subagents.length ?? 0) > 0
))
const loadingState = computed(() => resolveLivePanelLoading({
hasSnapshot: snapshot.value != null,
snapshotLoading: liveSnapshot.loading.value,
teamLoading: teamGroups.loading.value,
hasRuntimeActivity: hasRuntimeActivity.value,
hasError: liveSnapshot.error.value != null,
elapsedMs: loadingElapsedMs.value,
}))
const initialLoadingStage = computed<LivePanelLoadingStage>(() => (
loadingState.value.blocksContent ? loadingState.value.stage : null
))
const initialLoadingCopy = computed(() => {
switch (initialLoadingStage.value) {
case 'connecting': return { title: 'live.loading.connecting', hint: 'live.loading.connectingHint' }
case 'checking': return { title: 'live.loading.checking', hint: 'live.loading.checkingHint' }
case 'slow': return { title: 'live.loading.slow', hint: 'live.loading.slowHint' }
case 'failed': return { title: 'live.loading.failed', hint: 'live.loading.failedHint' }
default: return null
}
})
const autoRefresh = ref(true)
const drawerOpen = ref(false)
const detail = ref<LiveRunCard | null>(null)
const activeFilter = ref<FilterKey>('all')
let timer: ReturnType<typeof setInterval> | null = null
let loadingClock: ReturnType<typeof setInterval> | null = null
// ===== Lifecycle board mode =====
// Same snapshot, laid out across run/goal lifecycle columns. Goals are fetched
@ -458,6 +491,27 @@ async function refresh() {
}
}
function stopLoadingClock() {
if (!loadingClock) return
clearInterval(loadingClock)
loadingClock = null
}
function startLoadingClock() {
stopLoadingClock()
const startedAt = Date.now()
loadingElapsedMs.value = 0
loadingClock = setInterval(() => {
loadingElapsedMs.value = Date.now() - startedAt
if (snapshot.value != null || !liveSnapshot.loading.value) stopLoadingClock()
}, 100)
}
function retryInitialLoad() {
startLoadingClock()
void refresh()
}
watch(
() => [liveRoute.value.view, liveRoute.value.runId, liveRoute.value.taskId] as const,
() => routeHydrator.hydrate(liveRoute.value),
@ -565,12 +619,14 @@ async function confirmSweep() {
}
onMounted(() => {
startLoadingClock()
refresh()
timer = setInterval(refresh, 5000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
stopLoadingClock()
liveSnapshot.close()
teamGroups.close()
})
@ -583,6 +639,79 @@ onBeforeUnmount(() => {
.other-live-title { margin: 0 0 10px; color: var(--mc-text-primary); font-size: 14px; letter-spacing: 0; }
.team-runs-error { margin: -8px 0 12px; color: var(--mc-danger, #c13d3d); font-size: 12px; }
.live-loading-state {
min-height: 280px;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
border-top: 1px solid var(--mc-border-light);
border-bottom: 1px solid var(--mc-border-light);
color: var(--mc-text-secondary);
}
.live-loading-spinner {
width: 18px;
height: 18px;
flex: 0 0 auto;
border: 2px solid color-mix(in srgb, var(--mc-text-tertiary) 25%, transparent);
border-top-color: var(--mc-primary);
border-radius: 50%;
animation: live-loading-spin 0.8s linear infinite;
}
.live-loading-copy {
display: grid;
gap: 4px;
max-width: 520px;
}
.live-loading-copy strong {
color: var(--mc-text-primary);
font-size: 15px;
font-weight: 650;
}
.live-loading-copy span {
color: var(--mc-text-tertiary);
font-size: 13px;
line-height: 1.5;
}
.loading-retry {
min-height: 34px;
padding: 0 14px;
border: 1px solid var(--mc-border-light);
border-radius: 6px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
cursor: pointer;
}
.team-sync-note {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
margin: -4px 0 14px;
padding: 0 2px;
color: var(--mc-text-tertiary);
font-size: 12px;
}
.team-sync-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--mc-primary);
animation: chip-pulse 2.4s ease-in-out infinite;
}
@keyframes live-loading-spin {
to { transform: rotate(360deg); }
}
/* ===== Toolbar: live toggle + status filters + sweep ===== */
.live-toolbar {
display: flex;

View File

@ -76,6 +76,20 @@ describe('projectAgentRunGroups', () => {
describe('useAgentRunGroups hydration priority', () => {
beforeEach(() => vi.resetAllMocks())
it('skips team-run pagination when the live snapshot is empty', async () => {
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
vi.mocked(teamRunApi.listByTeamPage).mockResolvedValue({ data: { items: [run('running', [])], nextCursor: null } } as never)
const groups = useAgentRunGroups(ref(snapshot([])))
await groups.refreshForSnapshot()
expect(teamApi.list).not.toHaveBeenCalled()
expect(teamRunApi.listByTeamPage).not.toHaveBeenCalled()
expect(groups.runs.value).toEqual([])
expect(groups.loading.value).toBe(false)
expect(groups.error.value).toBeNull()
})
it('keeps an explicit route run when a later snapshot refresh completes first', async () => {
const routeDetail = deferred<unknown>()
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
@ -170,7 +184,7 @@ describe('useAgentRunGroups hydration priority', () => {
vi.mocked(teamRunApi.listByTeamPage)
.mockResolvedValueOnce({ data: { items: firstPage, nextCursor: 'cursor-2' } } as never)
.mockResolvedValueOnce({ data: { items: [finalRun], nextCursor: null } } as never)
const groups = useAgentRunGroups(ref(snapshot([])))
const groups = useAgentRunGroups(ref(snapshot([live('lead-conv')])))
await groups.refreshForSnapshot()
@ -189,7 +203,7 @@ describe('useAgentRunGroups hydration priority', () => {
vi.mocked(teamRunApi.listByTeamPage)
.mockResolvedValueOnce({ data: { items: [run('running', [])], nextCursor: 'loop' } } as never)
.mockResolvedValueOnce({ data: { items: [], nextCursor: 'loop' } } as never)
const groups = useAgentRunGroups(ref(snapshot([])))
const groups = useAgentRunGroups(ref(snapshot([live('lead-conv')])))
await expect(groups.refreshForSnapshot()).rejects.toThrow('cursor')
@ -210,7 +224,7 @@ describe('useAgentRunGroups hydration priority', () => {
if (teamId === '11') return pending.promise as never
return Promise.resolve({ data: { items: [], nextCursor: null } }) as never
})
const groups = useAgentRunGroups(ref(snapshot([])))
const groups = useAgentRunGroups(ref(snapshot([live('lead-conv')])))
const first = groups.refreshForSnapshot()
let settled = false
@ -237,7 +251,7 @@ describe('useAgentRunGroups hydration priority', () => {
const page = deferred<unknown>()
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
vi.mocked(teamRunApi.listByTeamPage).mockReturnValue(page.promise as never)
const groups = useAgentRunGroups(ref(snapshot([])))
const groups = useAgentRunGroups(ref(snapshot([live('lead-conv')])))
const refresh = groups.refreshForSnapshot()
await vi.waitFor(() => expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(1))

View File

@ -75,6 +75,28 @@ describe('useLiveSnapshot', () => {
history.resolve()
})
it('re-enters loading when retrying before the first snapshot', async () => {
const retry = deferred<unknown>()
const live = useLiveSnapshot({
load: vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockReturnValueOnce(retry.promise),
refreshRuns: vi.fn().mockResolvedValue(undefined),
})
await live.refresh()
expect(live.loading.value).toBe(false)
expect(live.error.value).toBeInstanceOf(Error)
const request = live.refresh()
expect(live.loading.value).toBe(true)
retry.resolve({ data: snapshot('retry') })
await request
expect(live.loading.value).toBe(false)
expect(live.snapshot.value?.runs[0].conversationId).toBe('retry')
})
})
function deferred<T>() {

View File

@ -110,6 +110,10 @@ async function mapConcurrent<T, R>(
return results
}
function hasRuntimeActivity(snapshot: LiveSnapshot | null): boolean {
return (snapshot?.runs?.length ?? 0) > 0 || (snapshot?.subagents?.length ?? 0) > 0
}
export function useAgentRunGroups(snapshot: Ref<LiveSnapshot | null>) {
const listedRuns = ref<TeamRun[]>([])
const ensuredRun = ref<TeamRun | null>(null)
@ -146,6 +150,11 @@ export function useAgentRunGroups(snapshot: Ref<LiveSnapshot | null>) {
async function runRefresh(request: number) {
try {
if (!hasRuntimeActivity(snapshot.value)) {
listedRuns.value = []
error.value = null
return
}
const teamsResponse: any = await teamApi.list()
const teams = teamsResponse?.data ?? []
if (closed || request !== listSequence) return

View File

@ -14,6 +14,7 @@ export function useLiveSnapshot({ load, refreshRuns }: Dependencies) {
async function refresh() {
const request = ++sequence
if (snapshot.value == null) loading.value = true
try {
const response = await load() as { data?: LiveSnapshot }
if (request !== sequence) return false

View File

@ -780,6 +780,18 @@ export default {
otherSessions: 'Other live sessions',
loadError: 'Team run context could not be refreshed.',
},
loading: {
connecting: 'Connecting to Live...',
connectingHint: 'Establishing the real-time status connection.',
checking: 'Checking which employees are working...',
checkingHint: 'Live is connected and reading the latest run status.',
slow: 'Live data is still on its way...',
slowHint: 'The service is responding more slowly than usual. Results will appear as soon as they arrive.',
syncingTeams: 'Live is ready. Organizing team task details...',
failed: 'Live could not be loaded',
failedHint: 'The Live service did not return data. You can try again.',
retry: 'Reload',
},
headline: {
loading: 'Looking around...',
allQuiet: 'All quiet. No one is working right now.',

View File

@ -2436,6 +2436,18 @@ export default {
otherSessions: '其他实时会话',
loadError: '团队运行上下文刷新失败。',
},
loading: {
connecting: '正在连接现场...',
connectingHint: '正在建立实时状态连接。',
checking: '正在检查正在工作的员工...',
checkingHint: '现场服务已连接,正在读取最新运行状态。',
slow: '现场数据仍在返回...',
slowHint: '服务响应比平时慢,页面会在数据到达后立即展示。',
syncingTeams: '现场已显示,正在整理团队任务信息...',
failed: '暂时无法加载现场',
failedHint: '现场服务没有返回数据,可以重新尝试。',
retry: '重新加载',
},
headline: {
loading: '正在看看...',
allQuiet: '一片安静。当前没有员工在工作。',

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { planAgentsPageLoads, shouldShowAgentsLiveBadge } from '../agentsPageLoading'
describe('planAgentsPageLoads', () => {
it('keeps the live view free of roster and duplicate badge requests', () => {
expect(planAgentsPageLoads({ view: 'live', isAdmin: true })).toEqual({
loadRoster: false,
loadAgentFormLookups: false,
pollLiveBadge: false,
})
})
it('loads roster data and live badge outside the live view for admins', () => {
expect(planAgentsPageLoads({ view: 'roster', isAdmin: true })).toEqual({
loadRoster: true,
loadAgentFormLookups: true,
pollLiveBadge: true,
})
})
it('does not poll the admin live badge for ordinary users', () => {
expect(planAgentsPageLoads({ view: 'roster', isAdmin: false })).toEqual({
loadRoster: true,
loadAgentFormLookups: true,
pollLiveBadge: false,
})
})
it('shows the live badge only as an entry hint outside the live view', () => {
expect(shouldShowAgentsLiveBadge({ view: 'live', running: 5 })).toBe(false)
expect(shouldShowAgentsLiveBadge({ view: 'roster', running: 5 })).toBe(true)
expect(shouldShowAgentsLiveBadge({ view: 'plans', running: 0 })).toBe(false)
})
})

View File

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { resolveLivePanelLoading } from '@/utils/livePanelLoading'
describe('resolveLivePanelLoading', () => {
it('starts by connecting to the live view', () => {
expect(resolveLivePanelLoading({
hasSnapshot: false,
snapshotLoading: true,
teamLoading: false,
hasRuntimeActivity: false,
hasError: false,
elapsedMs: 200,
})).toEqual({ stage: 'connecting', blocksContent: true })
})
it('explains that it is checking active employees after the connection phase', () => {
expect(resolveLivePanelLoading({
hasSnapshot: false,
snapshotLoading: true,
teamLoading: false,
hasRuntimeActivity: false,
hasError: false,
elapsedMs: 900,
})).toEqual({ stage: 'checking', blocksContent: true })
})
it('surfaces a slow response after two seconds', () => {
expect(resolveLivePanelLoading({
hasSnapshot: false,
snapshotLoading: true,
teamLoading: false,
hasRuntimeActivity: false,
hasError: false,
elapsedMs: 2_100,
})).toEqual({ stage: 'slow', blocksContent: true })
})
it('shows snapshot content while team task context continues syncing', () => {
expect(resolveLivePanelLoading({
hasSnapshot: true,
snapshotLoading: false,
teamLoading: true,
hasRuntimeActivity: true,
hasError: false,
elapsedMs: 300,
})).toEqual({ stage: 'syncingTeams', blocksContent: false })
})
it('does not show team syncing for an empty snapshot', () => {
expect(resolveLivePanelLoading({
hasSnapshot: true,
snapshotLoading: false,
teamLoading: true,
hasRuntimeActivity: false,
hasError: false,
elapsedMs: 300,
})).toEqual({ stage: null, blocksContent: false })
})
it('turns a failed initial request into a retryable state', () => {
expect(resolveLivePanelLoading({
hasSnapshot: false,
snapshotLoading: false,
teamLoading: false,
hasRuntimeActivity: false,
hasError: true,
elapsedMs: 300,
})).toEqual({ stage: 'failed', blocksContent: true })
})
})

View File

@ -0,0 +1,24 @@
export type AgentsPageView = 'roster' | 'live' | 'plans'
export function planAgentsPageLoads(options: {
view: AgentsPageView
isAdmin: boolean
}): {
loadRoster: boolean
loadAgentFormLookups: boolean
pollLiveBadge: boolean
} {
const isLiveView = options.view === 'live'
return {
loadRoster: !isLiveView,
loadAgentFormLookups: !isLiveView,
pollLiveBadge: options.isAdmin && !isLiveView,
}
}
export function shouldShowAgentsLiveBadge(options: {
view: AgentsPageView
running: number
}): boolean {
return options.view !== 'live' && options.running > 0
}

View File

@ -0,0 +1,36 @@
export type LivePanelLoadingStage = 'connecting' | 'checking' | 'slow' | 'syncingTeams' | 'failed' | null
interface LivePanelLoadingInput {
hasSnapshot: boolean
snapshotLoading: boolean
teamLoading: boolean
hasRuntimeActivity: boolean
hasError: boolean
elapsedMs: number
}
export interface LivePanelLoadingState {
stage: LivePanelLoadingStage
blocksContent: boolean
}
export function resolveLivePanelLoading(input: LivePanelLoadingInput): LivePanelLoadingState {
if (!input.hasSnapshot) {
if (input.hasError && !input.snapshotLoading) {
return { stage: 'failed', blocksContent: true }
}
if (input.elapsedMs >= 2_000) {
return { stage: 'slow', blocksContent: true }
}
if (input.elapsedMs >= 600) {
return { stage: 'checking', blocksContent: true }
}
return { stage: 'connecting', blocksContent: true }
}
if (input.teamLoading && input.hasRuntimeActivity) {
return { stage: 'syncingTeams', blocksContent: false }
}
return { stage: null, blocksContent: false }
}

View File

@ -22,10 +22,10 @@
:class="{ 'is-active': view === 'live' }"
@click="setView('live')"
>
<span v-if="liveRunning > 0" class="seg-pulse"></span>
<span v-if="showLiveBadge" class="seg-pulse"></span>
{{ t('agents.views.live') }}
<span
v-if="liveRunning > 0"
v-if="showLiveBadge"
class="seg-count"
:class="{ warn: liveStuck > 0 }"
>{{ liveRunning }}</span>
@ -741,19 +741,14 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount, defineAsyncComponent } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm'
import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi, liveApi, wikiApi } from '@/api/index'
import type { Agent } from '@/types/index'
import SkillIcon from '@/components/common/SkillIcon.vue'
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
import LivePanel from '@/components/live/LivePanel.vue'
import { parseAgentsLiveRoute, type AgentsView } from '@/composables/agentsLiveRouteState'
import PlanBoard from '@/components/agents/PlanBoard.vue'
import AgentGuideEditor from './Agents/components/AgentGuideEditor.vue'
import {
emptyProfile,
parsePrompt,
@ -765,12 +760,18 @@ import {
} from '@/utils/agentPromptProfile'
import { agentIconColor } from '@/utils/agentIconColor'
import { filterAgentBindingItems, filterAgentToolGroups } from '@/utils/agentBindingSearch'
import { planAgentsPageLoads, shouldShowAgentsLiveBadge } from '@/utils/agentsPageLoading'
import { useSkillName } from '@/composables/useSkillName'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const { resolveSkillName } = useSkillName()
const SkillIcon = defineAsyncComponent(() => import('@/components/common/SkillIcon.vue'))
const SkillIconPicker = defineAsyncComponent(() => import('@/components/common/SkillIconPicker.vue'))
const LivePanel = defineAsyncComponent(() => import('@/components/live/LivePanel.vue'))
const PlanBoard = defineAsyncComponent(() => import('@/components/agents/PlanBoard.vue'))
const AgentGuideEditor = defineAsyncComponent(() => import('./Agents/components/AgentGuideEditor.vue'))
const agents = ref<Agent[]>([])
const searchText = ref('')
const activeFilter = ref('all')
@ -1196,7 +1197,14 @@ const view = ref<AgentView>(
)
const liveRunning = ref(0)
const liveStuck = ref(0)
const showLiveBadge = computed(() => shouldShowAgentsLiveBadge({
view: view.value,
running: liveRunning.value,
}))
let livePollTimer: ReturnType<typeof setInterval> | null = null
let agentsLoaded = false
let modelOptionsLoaded = false
let dshDiagnosticsLoaded = false
function setView(next: AgentView) {
view.value = next
@ -1226,31 +1234,59 @@ async function loadDshDiagnostics() {
try {
const res: any = await liveApi.dshDiagnostics()
dshDiagnostics.value = res?.data ?? res
dshDiagnosticsLoaded = true
} catch {
dshDiagnostics.value = null
}
}
onMounted(() => {
loadAgents()
loadDshDiagnostics()
// RFC-03 G1: load models once for the per-Agent override dropdown.
// Failure is non-fatal the dropdown just shows only "global default".
loadAvailableModels()
if (isAdminRole.value) {
refreshLiveCounts()
livePollTimer = setInterval(refreshLiveCounts, 10_000)
}
applyViewLoadPlan()
})
onBeforeUnmount(() => {
if (livePollTimer) clearInterval(livePollTimer)
stopLiveBadgePolling()
})
watch(view, () => applyViewLoadPlan())
function applyViewLoadPlan() {
const plan = planAgentsPageLoads({ view: view.value, isAdmin: isAdminRole.value })
if (plan.loadRoster) ensureAgentsLoaded()
if (plan.loadAgentFormLookups) ensureAgentFormLookupsLoaded()
if (plan.pollLiveBadge) startLiveBadgePolling()
else stopLiveBadgePolling()
}
function startLiveBadgePolling() {
if (livePollTimer) return
refreshLiveCounts()
livePollTimer = setInterval(refreshLiveCounts, 10_000)
}
function stopLiveBadgePolling() {
if (!livePollTimer) return
clearInterval(livePollTimer)
livePollTimer = null
}
function ensureAgentsLoaded() {
if (agentsLoaded) return
loadAgents()
}
function ensureAgentFormLookupsLoaded() {
if (!dshDiagnosticsLoaded) loadDshDiagnostics()
// RFC-03 G1: load models once for the per-Agent override dropdown.
// Failure is non-fatal the dropdown just shows only "global default".
if (!modelOptionsLoaded) loadAvailableModels()
}
async function loadAgents() {
try {
const res: any = await agentApi.list()
agents.value = res.data || []
agentsLoaded = true
} catch {
mcToast.error(t('agents.messages.loadFailed'))
}
@ -1265,6 +1301,7 @@ async function loadAvailableModels() {
provider: m.provider,
modelName: m.modelName,
}))
modelOptionsLoaded = true
} catch {
// Silent the picker still works (empty list = only "default" option).
}
@ -1277,6 +1314,7 @@ function openCreateModal() {
}
function openBlankCreateModal() {
ensureAgentFormLookupsLoaded()
showTemplateSelector.value = false
editingAgent.value = null
form.value = defaultForm()
@ -1375,6 +1413,7 @@ async function applyTemplate(id: string) {
}
async function openEditModal(agent: Agent) {
ensureAgentFormLookupsLoaded()
editingAgent.value = agent
form.value = {
name: agent.name,