Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions apps/electron/src/renderer/atoms/agent-atoms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, expect, test } from 'bun:test'
import { createStore } from 'jotai/vanilla'
import type { AskUserRequest, ExitPlanModeRequest, PermissionRequest } from '@proma/shared'
import {
agentSessionIndicatorMapAtom,
agentStreamErrorsAtom,
agentStreamingStatesAtom,
allPendingAskUserRequestsAtom,
allPendingExitPlanRequestsAtom,
allPendingPermissionRequestsAtom,
unviewedCompletedSessionIdsAtom,
type AgentStreamState,
} from './agent-atoms'

function runningState(overrides: Partial<AgentStreamState> = {}): AgentStreamState {
return {
running: true,
content: '',
toolActivities: [],
startedAt: 1_000,
...overrides,
}
}

function permissionRequest(sessionId: string): PermissionRequest {
return {
requestId: `perm-${sessionId}`,
sessionId,
toolName: 'Bash',
toolInput: { command: 'bun test' },
description: '运行测试',
dangerLevel: 'normal',
}
}

function askUserRequest(sessionId: string): AskUserRequest {
return {
requestId: `ask-${sessionId}`,
sessionId,
questions: [{ question: '继续吗?', options: [] }],
toolInput: {},
}
}

function exitPlanRequest(sessionId: string): ExitPlanModeRequest {
return {
requestId: `plan-${sessionId}`,
sessionId,
toolInput: {},
allowedPrompts: [],
}
}

describe('agentSessionIndicatorMapAtom', () => {
test('given no session state when deriving indicators then idle sessions are omitted', () => {
const store = createStore()

expect(store.get(agentSessionIndicatorMapAtom).has('idle-session')).toBe(false)
})

test('given running stream state when deriving indicators then session is running', () => {
const store = createStore()
store.set(agentStreamingStatesAtom, new Map([
['session-running', runningState()],
]))

expect(store.get(agentSessionIndicatorMapAtom).get('session-running')).toBe('running')
})

test('given unviewed completed session when deriving indicators then session is completed', () => {
const store = createStore()
store.set(unviewedCompletedSessionIdsAtom, new Set(['session-completed']))

expect(store.get(agentSessionIndicatorMapAtom).get('session-completed')).toBe('completed')
})

test('given stream error without running state when deriving indicators then session is error', () => {
const store = createStore()
store.set(agentStreamErrorsAtom, new Map([
['session-error', 'API 服务不可用'],
]))

expect(store.get(agentSessionIndicatorMapAtom).get('session-error')).toBe('error')
})

test('given running stream and stream error when deriving indicators then error overrides running', () => {
const store = createStore()
store.set(agentStreamingStatesAtom, new Map([
['session-error', runningState()],
]))
store.set(agentStreamErrorsAtom, new Map([
['session-error', '网络已断开'],
]))

expect(store.get(agentSessionIndicatorMapAtom).get('session-error')).toBe('error')
})

test('given completed session and stream error when deriving indicators then error overrides completed', () => {
const store = createStore()
store.set(unviewedCompletedSessionIdsAtom, new Set(['session-error']))
store.set(agentStreamErrorsAtom, new Map([
['session-error', '重试失败'],
]))

expect(store.get(agentSessionIndicatorMapAtom).get('session-error')).toBe('error')
})

test('given pending permission request when deriving indicators then blocked overrides error and running', () => {
const store = createStore()
store.set(agentStreamingStatesAtom, new Map([
['session-blocked', runningState()],
]))
store.set(agentStreamErrorsAtom, new Map([
['session-blocked', '旧错误不应盖过待审批'],
]))
store.set(allPendingPermissionRequestsAtom, new Map([
['session-blocked', [permissionRequest('session-blocked')]],
]))

expect(store.get(agentSessionIndicatorMapAtom).get('session-blocked')).toBe('blocked')
})

test('given pending AskUser request when deriving indicators then session is blocked', () => {
const store = createStore()
store.set(agentStreamingStatesAtom, new Map([
['session-ask', runningState()],
]))
store.set(allPendingAskUserRequestsAtom, new Map([
['session-ask', [askUserRequest('session-ask')]],
]))

expect(store.get(agentSessionIndicatorMapAtom).get('session-ask')).toBe('blocked')
})

test('given pending ExitPlan request when deriving indicators then session is blocked', () => {
const store = createStore()
store.set(agentStreamingStatesAtom, new Map([
['session-plan', runningState()],
]))
store.set(allPendingExitPlanRequestsAtom, new Map([
['session-plan', [exitPlanRequest('session-plan')]],
]))

expect(store.get(agentSessionIndicatorMapAtom).get('session-plan')).toBe('blocked')
})
})
11 changes: 9 additions & 2 deletions apps/electron/src/renderer/atoms/agent-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ export const agentRunningSessionIdsAtom = atom<Set<string>>((get) => {
})

/** 侧边栏会话指示点状态 */
export type SessionIndicatorStatus = 'idle' | 'running' | 'blocked' | 'completed'
export type SessionIndicatorStatus = 'idle' | 'running' | 'blocked' | 'completed' | 'error'

/** 已完成但用户尚未查看的会话 ID 集合 */
export const unviewedCompletedSessionIdsAtom = atom<Set<string>>(new Set<string>())
Expand Down Expand Up @@ -580,14 +580,15 @@ export const dockBadgeCountAtom = atom<number>((get) => {

/**
* 每个会话的指示点状态(只包含非 idle 的会话)
* 优先级:blocked > running > completed > idle
* 优先级:blocked > error > running > completed > idle
*/
export const agentSessionIndicatorMapAtom = atom<Map<string, SessionIndicatorStatus>>((get) => {
const streamStates = get(agentStreamingStatesAtom)
const pendingPerms = get(allPendingPermissionRequestsAtom)
const pendingAskUser = get(allPendingAskUserRequestsAtom)
const pendingExitPlan = get(allPendingExitPlanRequestsAtom)
const unviewedCompleted = get(unviewedCompletedSessionIdsAtom)
const streamErrors = get(agentStreamErrorsAtom)

const map = new Map<string, SessionIndicatorStatus>()

Expand All @@ -599,6 +600,12 @@ export const agentSessionIndicatorMapAtom = atom<Map<string, SessionIndicatorSta
map.set(id, hasBlock ? 'blocked' : 'running')
}

for (const id of streamErrors.keys()) {
if (map.get(id) !== 'blocked') {
map.set(id, 'error')
}
}

for (const id of unviewedCompleted) {
if (!map.has(id)) {
map.set(id, 'completed')
Expand Down
47 changes: 45 additions & 2 deletions apps/electron/src/renderer/components/agent/AgentHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
import * as React from 'react'
import { useAtomValue, useSetAtom } from 'jotai'
import { Pencil, Check, X } from 'lucide-react'
import { agentSessionsAtom } from '@/atoms/agent-atoms'
import { agentSessionsAtom, agentSessionIndicatorMapAtom, agentSessionStreamingStateAtomFamily, agentStreamErrorsAtom, type SessionIndicatorStatus } from '@/atoms/agent-atoms'
import { tabsAtom, updateTabTitle } from '@/atoms/tab-atoms'
import type { AgentSessionMeta } from '@proma/shared'
import { AgentStatusPulseLight, buildAgentDelegationProgressSummary } from './AgentStatusPulseLight'
import { replaceAgentSessionInFreshnessOrder } from '@/lib/agent-session-list'
import { detectIsWindows, WINDOW_CONTROLS_INSET_RIGHT } from '@/lib/platform'
import { cn } from '@/lib/utils'
Expand All @@ -19,10 +21,45 @@ interface AgentHeaderProps {
sessionId: string
}

function getDirectDelegatedChildren(sessions: AgentSessionMeta[], parentSessionId: string): AgentSessionMeta[] {
return sessions.filter((session) => session.parentSessionId === parentSessionId && session.sourceDelegationId)
}

function aggregateHeaderStatus(
sessionId: string,
sessions: AgentSessionMeta[],
indicatorMap: Map<string, SessionIndicatorStatus>,
): SessionIndicatorStatus {
const childSessions = getDirectDelegatedChildren(sessions, sessionId)
const statuses = [
indicatorMap.get(sessionId) ?? 'idle',
...childSessions.map((session) => {
const status = indicatorMap.get(session.id)
if (status) return status
return session.delegationStatus === 'running' ? 'running' : 'idle'
}),
]

if (statuses.includes('blocked')) return 'blocked'
if (statuses.includes('error')) return 'error'
if (statuses.includes('running')) return 'running'
if (statuses.includes('completed')) return 'completed'
return 'idle'
}

export function AgentHeader({ sessionId }: AgentHeaderProps): React.ReactElement | null {
const isWindows = React.useMemo(() => detectIsWindows(), [])
const sessions = useAtomValue(agentSessionsAtom)
const indicatorMap = useAtomValue(agentSessionIndicatorMapAtom)
const streamState = useAtomValue(agentSessionStreamingStateAtomFamily(sessionId))
const streamErrors = useAtomValue(agentStreamErrorsAtom)
const session = sessions.find((s) => s.id === sessionId) ?? null
const status = aggregateHeaderStatus(sessionId, sessions, indicatorMap)
const errorMessage = streamErrors.get(sessionId) ?? null
const delegationSummary = buildAgentDelegationProgressSummary(
getDirectDelegatedChildren(sessions, sessionId),
streamState?.running ? streamState.startedAt : undefined,
)
const setAgentSessions = useSetAtom(agentSessionsAtom)
const setTabs = useSetAtom(tabsAtom)
const [editing, setEditing] = React.useState(false)
Expand Down Expand Up @@ -102,9 +139,15 @@ export function AgentHeader({ sessionId }: AgentHeaderProps): React.ReactElement
</div>
) : (
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span className="truncate text-sm font-medium text-foreground">
<span className="min-w-0 truncate text-sm font-medium text-foreground">
{session.title}
</span>
<AgentStatusPulseLight
status={status}
streamState={streamState}
errorMessage={errorMessage}
delegationSummary={delegationSummary}
/>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
Expand Down
Loading