diff --git a/mateclaw-ui/src/components/live/LivePanel.vue b/mateclaw-ui/src/components/live/LivePanel.vue
index 47cccb98..c47a15af 100644
--- a/mateclaw-ui/src/components/live/LivePanel.vue
+++ b/mateclaw-ui/src/components/live/LivePanel.vue
@@ -65,14 +65,22 @@
-
-
-
-
+
+
+
+ {{ t(initialLoadingCopy.title) }}
+ {{ t(initialLoadingCopy.hint) }}
+
+
+
+ {{ t('live.loading.syncingTeams') }}
+
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(() => (
+ 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(null)
const activeFilter = ref('all')
let timer: ReturnType | null = null
+let loadingClock: ReturnType | 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;
diff --git a/mateclaw-ui/src/composables/__tests__/useAgentRunGroups.test.ts b/mateclaw-ui/src/composables/__tests__/useAgentRunGroups.test.ts
index 5931db5a..cfacbaaa 100644
--- a/mateclaw-ui/src/composables/__tests__/useAgentRunGroups.test.ts
+++ b/mateclaw-ui/src/composables/__tests__/useAgentRunGroups.test.ts
@@ -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()
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()
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))
diff --git a/mateclaw-ui/src/composables/__tests__/useLiveSnapshot.test.ts b/mateclaw-ui/src/composables/__tests__/useLiveSnapshot.test.ts
index 61a928b8..a001c6ad 100644
--- a/mateclaw-ui/src/composables/__tests__/useLiveSnapshot.test.ts
+++ b/mateclaw-ui/src/composables/__tests__/useLiveSnapshot.test.ts
@@ -75,6 +75,28 @@ describe('useLiveSnapshot', () => {
history.resolve()
})
+
+ it('re-enters loading when retrying before the first snapshot', async () => {
+ const retry = deferred()
+ 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() {
diff --git a/mateclaw-ui/src/composables/useAgentRunGroups.ts b/mateclaw-ui/src/composables/useAgentRunGroups.ts
index 1567e683..7fb365f5 100644
--- a/mateclaw-ui/src/composables/useAgentRunGroups.ts
+++ b/mateclaw-ui/src/composables/useAgentRunGroups.ts
@@ -110,6 +110,10 @@ async function mapConcurrent(
return results
}
+function hasRuntimeActivity(snapshot: LiveSnapshot | null): boolean {
+ return (snapshot?.runs?.length ?? 0) > 0 || (snapshot?.subagents?.length ?? 0) > 0
+}
+
export function useAgentRunGroups(snapshot: Ref) {
const listedRuns = ref([])
const ensuredRun = ref(null)
@@ -146,6 +150,11 @@ export function useAgentRunGroups(snapshot: Ref) {
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
diff --git a/mateclaw-ui/src/composables/useLiveSnapshot.ts b/mateclaw-ui/src/composables/useLiveSnapshot.ts
index 20027051..bf2cad37 100644
--- a/mateclaw-ui/src/composables/useLiveSnapshot.ts
+++ b/mateclaw-ui/src/composables/useLiveSnapshot.ts
@@ -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
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index 34ce5962..a62f1617 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -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.',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index fe31c3ea..1e005961 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -2436,6 +2436,18 @@ export default {
otherSessions: '其他实时会话',
loadError: '团队运行上下文刷新失败。',
},
+ loading: {
+ connecting: '正在连接现场...',
+ connectingHint: '正在建立实时状态连接。',
+ checking: '正在检查正在工作的员工...',
+ checkingHint: '现场服务已连接,正在读取最新运行状态。',
+ slow: '现场数据仍在返回...',
+ slowHint: '服务响应比平时慢,页面会在数据到达后立即展示。',
+ syncingTeams: '现场已显示,正在整理团队任务信息...',
+ failed: '暂时无法加载现场',
+ failedHint: '现场服务没有返回数据,可以重新尝试。',
+ retry: '重新加载',
+ },
headline: {
loading: '正在看看...',
allQuiet: '一片安静。当前没有员工在工作。',
diff --git a/mateclaw-ui/src/utils/__tests__/agentsPageLoading.test.ts b/mateclaw-ui/src/utils/__tests__/agentsPageLoading.test.ts
new file mode 100644
index 00000000..921931f4
--- /dev/null
+++ b/mateclaw-ui/src/utils/__tests__/agentsPageLoading.test.ts
@@ -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)
+ })
+})
diff --git a/mateclaw-ui/src/utils/__tests__/livePanelLoading.test.ts b/mateclaw-ui/src/utils/__tests__/livePanelLoading.test.ts
new file mode 100644
index 00000000..1a729b01
--- /dev/null
+++ b/mateclaw-ui/src/utils/__tests__/livePanelLoading.test.ts
@@ -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 })
+ })
+})
diff --git a/mateclaw-ui/src/utils/agentsPageLoading.ts b/mateclaw-ui/src/utils/agentsPageLoading.ts
new file mode 100644
index 00000000..945bcfc1
--- /dev/null
+++ b/mateclaw-ui/src/utils/agentsPageLoading.ts
@@ -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
+}
diff --git a/mateclaw-ui/src/utils/livePanelLoading.ts b/mateclaw-ui/src/utils/livePanelLoading.ts
new file mode 100644
index 00000000..1bed1a79
--- /dev/null
+++ b/mateclaw-ui/src/utils/livePanelLoading.ts
@@ -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 }
+}
diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue
index 5aa2c02b..3cba51ea 100644
--- a/mateclaw-ui/src/views/Agents.vue
+++ b/mateclaw-ui/src/views/Agents.vue
@@ -22,10 +22,10 @@
:class="{ 'is-active': view === 'live' }"
@click="setView('live')"
>
-
+
{{ t('agents.views.live') }}
{{ liveRunning }}
@@ -741,19 +741,14 @@