mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
fix(live): speed up live status loading (#615)
This commit is contained in:
parent
bc2b4613f7
commit
ab5a0a651b
@ -65,14 +65,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<div v-if="initialLoadingCopy" class="live-loading-state" role="status" aria-live="polite">
|
||||||
<div v-if="isInitialLoading" class="cards-grid">
|
<span v-if="initialLoadingStage !== 'failed'" class="live-loading-spinner" aria-hidden="true"></span>
|
||||||
<div v-for="i in 3" :key="i" class="agent-card mc-surface-card agent-card-skeleton">
|
<div class="live-loading-copy">
|
||||||
<el-skeleton :rows="2" animated />
|
<strong>{{ t(initialLoadingCopy.title) }}</strong>
|
||||||
|
<span>{{ t(initialLoadingCopy.hint) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button v-if="initialLoadingStage === 'failed'" class="loading-retry" @click="retryInitialLoad">
|
||||||
|
{{ t('live.loading.retry') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<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
|
<AgentRunGroups
|
||||||
:groups="teamProjection.groups"
|
:groups="teamProjection.groups"
|
||||||
:selected-run-id="selectedTeamRunId"
|
:selected-run-id="selectedTeamRunId"
|
||||||
@ -213,6 +221,7 @@ import { useLiveSnapshot } from '@/composables/useLiveSnapshot'
|
|||||||
import { mcConfirm } from '@/components/common/useConfirm'
|
import { mcConfirm } from '@/components/common/useConfirm'
|
||||||
import { liveApi, goalApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard, type Goal } from '@/api'
|
import { liveApi, goalApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard, type Goal } from '@/api'
|
||||||
import { buildTeamRunRoute } from '@/components/team-run/teamRunPresentation'
|
import { buildTeamRunRoute } from '@/components/team-run/teamRunPresentation'
|
||||||
|
import { resolveLivePanelLoading, type LivePanelLoadingStage } from '@/utils/livePanelLoading'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -243,12 +252,36 @@ const routeHydrator = createAgentsLiveRouteHydrator({
|
|||||||
})
|
})
|
||||||
const selectedTeamRunId = computed(() => selection.value.selectedRunId)
|
const selectedTeamRunId = computed(() => selection.value.selectedRunId)
|
||||||
const selectedTeamTaskId = computed(() => selection.value.selectedTaskId)
|
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 autoRefresh = ref(true)
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const detail = ref<LiveRunCard | null>(null)
|
const detail = ref<LiveRunCard | null>(null)
|
||||||
const activeFilter = ref<FilterKey>('all')
|
const activeFilter = ref<FilterKey>('all')
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let loadingClock: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
// ===== Lifecycle board mode =====
|
// ===== Lifecycle board mode =====
|
||||||
// Same snapshot, laid out across run/goal lifecycle columns. Goals are fetched
|
// 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(
|
watch(
|
||||||
() => [liveRoute.value.view, liveRoute.value.runId, liveRoute.value.taskId] as const,
|
() => [liveRoute.value.view, liveRoute.value.runId, liveRoute.value.taskId] as const,
|
||||||
() => routeHydrator.hydrate(liveRoute.value),
|
() => routeHydrator.hydrate(liveRoute.value),
|
||||||
@ -565,12 +619,14 @@ async function confirmSweep() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
startLoadingClock()
|
||||||
refresh()
|
refresh()
|
||||||
timer = setInterval(refresh, 5000)
|
timer = setInterval(refresh, 5000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (timer) clearInterval(timer)
|
if (timer) clearInterval(timer)
|
||||||
|
stopLoadingClock()
|
||||||
liveSnapshot.close()
|
liveSnapshot.close()
|
||||||
teamGroups.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; }
|
.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; }
|
.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 ===== */
|
/* ===== Toolbar: live toggle + status filters + sweep ===== */
|
||||||
.live-toolbar {
|
.live-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -76,6 +76,20 @@ describe('projectAgentRunGroups', () => {
|
|||||||
describe('useAgentRunGroups hydration priority', () => {
|
describe('useAgentRunGroups hydration priority', () => {
|
||||||
beforeEach(() => vi.resetAllMocks())
|
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 () => {
|
it('keeps an explicit route run when a later snapshot refresh completes first', async () => {
|
||||||
const routeDetail = deferred<unknown>()
|
const routeDetail = deferred<unknown>()
|
||||||
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
|
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
|
||||||
@ -170,7 +184,7 @@ describe('useAgentRunGroups hydration priority', () => {
|
|||||||
vi.mocked(teamRunApi.listByTeamPage)
|
vi.mocked(teamRunApi.listByTeamPage)
|
||||||
.mockResolvedValueOnce({ data: { items: firstPage, nextCursor: 'cursor-2' } } as never)
|
.mockResolvedValueOnce({ data: { items: firstPage, nextCursor: 'cursor-2' } } as never)
|
||||||
.mockResolvedValueOnce({ data: { items: [finalRun], nextCursor: null } } 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()
|
await groups.refreshForSnapshot()
|
||||||
|
|
||||||
@ -189,7 +203,7 @@ describe('useAgentRunGroups hydration priority', () => {
|
|||||||
vi.mocked(teamRunApi.listByTeamPage)
|
vi.mocked(teamRunApi.listByTeamPage)
|
||||||
.mockResolvedValueOnce({ data: { items: [run('running', [])], nextCursor: 'loop' } } as never)
|
.mockResolvedValueOnce({ data: { items: [run('running', [])], nextCursor: 'loop' } } as never)
|
||||||
.mockResolvedValueOnce({ data: { items: [], 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')
|
await expect(groups.refreshForSnapshot()).rejects.toThrow('cursor')
|
||||||
|
|
||||||
@ -210,7 +224,7 @@ describe('useAgentRunGroups hydration priority', () => {
|
|||||||
if (teamId === '11') return pending.promise as never
|
if (teamId === '11') return pending.promise as never
|
||||||
return Promise.resolve({ data: { items: [], nextCursor: null } }) 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()
|
const first = groups.refreshForSnapshot()
|
||||||
let settled = false
|
let settled = false
|
||||||
@ -237,7 +251,7 @@ describe('useAgentRunGroups hydration priority', () => {
|
|||||||
const page = deferred<unknown>()
|
const page = deferred<unknown>()
|
||||||
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
|
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
|
||||||
vi.mocked(teamRunApi.listByTeamPage).mockReturnValue(page.promise 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()
|
const refresh = groups.refreshForSnapshot()
|
||||||
await vi.waitFor(() => expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(1))
|
await vi.waitFor(() => expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(1))
|
||||||
|
|||||||
@ -75,6 +75,28 @@ describe('useLiveSnapshot', () => {
|
|||||||
|
|
||||||
history.resolve()
|
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>() {
|
function deferred<T>() {
|
||||||
|
|||||||
@ -110,6 +110,10 @@ async function mapConcurrent<T, R>(
|
|||||||
return results
|
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>) {
|
export function useAgentRunGroups(snapshot: Ref<LiveSnapshot | null>) {
|
||||||
const listedRuns = ref<TeamRun[]>([])
|
const listedRuns = ref<TeamRun[]>([])
|
||||||
const ensuredRun = ref<TeamRun | null>(null)
|
const ensuredRun = ref<TeamRun | null>(null)
|
||||||
@ -146,6 +150,11 @@ export function useAgentRunGroups(snapshot: Ref<LiveSnapshot | null>) {
|
|||||||
|
|
||||||
async function runRefresh(request: number) {
|
async function runRefresh(request: number) {
|
||||||
try {
|
try {
|
||||||
|
if (!hasRuntimeActivity(snapshot.value)) {
|
||||||
|
listedRuns.value = []
|
||||||
|
error.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
const teamsResponse: any = await teamApi.list()
|
const teamsResponse: any = await teamApi.list()
|
||||||
const teams = teamsResponse?.data ?? []
|
const teams = teamsResponse?.data ?? []
|
||||||
if (closed || request !== listSequence) return
|
if (closed || request !== listSequence) return
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export function useLiveSnapshot({ load, refreshRuns }: Dependencies) {
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const request = ++sequence
|
const request = ++sequence
|
||||||
|
if (snapshot.value == null) loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await load() as { data?: LiveSnapshot }
|
const response = await load() as { data?: LiveSnapshot }
|
||||||
if (request !== sequence) return false
|
if (request !== sequence) return false
|
||||||
|
|||||||
@ -780,6 +780,18 @@ export default {
|
|||||||
otherSessions: 'Other live sessions',
|
otherSessions: 'Other live sessions',
|
||||||
loadError: 'Team run context could not be refreshed.',
|
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: {
|
headline: {
|
||||||
loading: 'Looking around...',
|
loading: 'Looking around...',
|
||||||
allQuiet: 'All quiet. No one is working right now.',
|
allQuiet: 'All quiet. No one is working right now.',
|
||||||
|
|||||||
@ -2436,6 +2436,18 @@ export default {
|
|||||||
otherSessions: '其他实时会话',
|
otherSessions: '其他实时会话',
|
||||||
loadError: '团队运行上下文刷新失败。',
|
loadError: '团队运行上下文刷新失败。',
|
||||||
},
|
},
|
||||||
|
loading: {
|
||||||
|
connecting: '正在连接现场...',
|
||||||
|
connectingHint: '正在建立实时状态连接。',
|
||||||
|
checking: '正在检查正在工作的员工...',
|
||||||
|
checkingHint: '现场服务已连接,正在读取最新运行状态。',
|
||||||
|
slow: '现场数据仍在返回...',
|
||||||
|
slowHint: '服务响应比平时慢,页面会在数据到达后立即展示。',
|
||||||
|
syncingTeams: '现场已显示,正在整理团队任务信息...',
|
||||||
|
failed: '暂时无法加载现场',
|
||||||
|
failedHint: '现场服务没有返回数据,可以重新尝试。',
|
||||||
|
retry: '重新加载',
|
||||||
|
},
|
||||||
headline: {
|
headline: {
|
||||||
loading: '正在看看...',
|
loading: '正在看看...',
|
||||||
allQuiet: '一片安静。当前没有员工在工作。',
|
allQuiet: '一片安静。当前没有员工在工作。',
|
||||||
|
|||||||
34
mateclaw-ui/src/utils/__tests__/agentsPageLoading.test.ts
Normal file
34
mateclaw-ui/src/utils/__tests__/agentsPageLoading.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
70
mateclaw-ui/src/utils/__tests__/livePanelLoading.test.ts
Normal file
70
mateclaw-ui/src/utils/__tests__/livePanelLoading.test.ts
Normal 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
24
mateclaw-ui/src/utils/agentsPageLoading.ts
Normal file
24
mateclaw-ui/src/utils/agentsPageLoading.ts
Normal 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
|
||||||
|
}
|
||||||
36
mateclaw-ui/src/utils/livePanelLoading.ts
Normal file
36
mateclaw-ui/src/utils/livePanelLoading.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -22,10 +22,10 @@
|
|||||||
:class="{ 'is-active': view === 'live' }"
|
:class="{ 'is-active': view === 'live' }"
|
||||||
@click="setView('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') }}
|
{{ t('agents.views.live') }}
|
||||||
<span
|
<span
|
||||||
v-if="liveRunning > 0"
|
v-if="showLiveBadge"
|
||||||
class="seg-count"
|
class="seg-count"
|
||||||
:class="{ warn: liveStuck > 0 }"
|
:class="{ warn: liveStuck > 0 }"
|
||||||
>{{ liveRunning }}</span>
|
>{{ liveRunning }}</span>
|
||||||
@ -741,19 +741,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mcToast } from '@/composables/useMcToast'
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
import { mcConfirm } from '@/components/common/useConfirm'
|
import { mcConfirm } from '@/components/common/useConfirm'
|
||||||
import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi, liveApi, wikiApi } from '@/api/index'
|
import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi, liveApi, wikiApi } from '@/api/index'
|
||||||
import type { Agent } from '@/types/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 { parseAgentsLiveRoute, type AgentsView } from '@/composables/agentsLiveRouteState'
|
||||||
import PlanBoard from '@/components/agents/PlanBoard.vue'
|
|
||||||
import AgentGuideEditor from './Agents/components/AgentGuideEditor.vue'
|
|
||||||
import {
|
import {
|
||||||
emptyProfile,
|
emptyProfile,
|
||||||
parsePrompt,
|
parsePrompt,
|
||||||
@ -765,12 +760,18 @@ import {
|
|||||||
} from '@/utils/agentPromptProfile'
|
} from '@/utils/agentPromptProfile'
|
||||||
import { agentIconColor } from '@/utils/agentIconColor'
|
import { agentIconColor } from '@/utils/agentIconColor'
|
||||||
import { filterAgentBindingItems, filterAgentToolGroups } from '@/utils/agentBindingSearch'
|
import { filterAgentBindingItems, filterAgentToolGroups } from '@/utils/agentBindingSearch'
|
||||||
|
import { planAgentsPageLoads, shouldShowAgentsLiveBadge } from '@/utils/agentsPageLoading'
|
||||||
import { useSkillName } from '@/composables/useSkillName'
|
import { useSkillName } from '@/composables/useSkillName'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { resolveSkillName } = useSkillName()
|
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 agents = ref<Agent[]>([])
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const activeFilter = ref('all')
|
const activeFilter = ref('all')
|
||||||
@ -1196,7 +1197,14 @@ const view = ref<AgentView>(
|
|||||||
)
|
)
|
||||||
const liveRunning = ref(0)
|
const liveRunning = ref(0)
|
||||||
const liveStuck = ref(0)
|
const liveStuck = ref(0)
|
||||||
|
const showLiveBadge = computed(() => shouldShowAgentsLiveBadge({
|
||||||
|
view: view.value,
|
||||||
|
running: liveRunning.value,
|
||||||
|
}))
|
||||||
let livePollTimer: ReturnType<typeof setInterval> | null = null
|
let livePollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let agentsLoaded = false
|
||||||
|
let modelOptionsLoaded = false
|
||||||
|
let dshDiagnosticsLoaded = false
|
||||||
|
|
||||||
function setView(next: AgentView) {
|
function setView(next: AgentView) {
|
||||||
view.value = next
|
view.value = next
|
||||||
@ -1226,31 +1234,59 @@ async function loadDshDiagnostics() {
|
|||||||
try {
|
try {
|
||||||
const res: any = await liveApi.dshDiagnostics()
|
const res: any = await liveApi.dshDiagnostics()
|
||||||
dshDiagnostics.value = res?.data ?? res
|
dshDiagnostics.value = res?.data ?? res
|
||||||
|
dshDiagnosticsLoaded = true
|
||||||
} catch {
|
} catch {
|
||||||
dshDiagnostics.value = null
|
dshDiagnostics.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadAgents()
|
applyViewLoadPlan()
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
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() {
|
async function loadAgents() {
|
||||||
try {
|
try {
|
||||||
const res: any = await agentApi.list()
|
const res: any = await agentApi.list()
|
||||||
agents.value = res.data || []
|
agents.value = res.data || []
|
||||||
|
agentsLoaded = true
|
||||||
} catch {
|
} catch {
|
||||||
mcToast.error(t('agents.messages.loadFailed'))
|
mcToast.error(t('agents.messages.loadFailed'))
|
||||||
}
|
}
|
||||||
@ -1265,6 +1301,7 @@ async function loadAvailableModels() {
|
|||||||
provider: m.provider,
|
provider: m.provider,
|
||||||
modelName: m.modelName,
|
modelName: m.modelName,
|
||||||
}))
|
}))
|
||||||
|
modelOptionsLoaded = true
|
||||||
} catch {
|
} catch {
|
||||||
// Silent — the picker still works (empty list = only "default" option).
|
// Silent — the picker still works (empty list = only "default" option).
|
||||||
}
|
}
|
||||||
@ -1277,6 +1314,7 @@ function openCreateModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openBlankCreateModal() {
|
function openBlankCreateModal() {
|
||||||
|
ensureAgentFormLookupsLoaded()
|
||||||
showTemplateSelector.value = false
|
showTemplateSelector.value = false
|
||||||
editingAgent.value = null
|
editingAgent.value = null
|
||||||
form.value = defaultForm()
|
form.value = defaultForm()
|
||||||
@ -1375,6 +1413,7 @@ async function applyTemplate(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openEditModal(agent: Agent) {
|
async function openEditModal(agent: Agent) {
|
||||||
|
ensureAgentFormLookupsLoaded()
|
||||||
editingAgent.value = agent
|
editingAgent.value = agent
|
||||||
form.value = {
|
form.value = {
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user