diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx new file mode 100644 index 000000000..00d296ed4 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx @@ -0,0 +1,245 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { BtwSessionPanel } from './BtwSessionPanel'; +import { useReviewActionBarStore } from '../../store/deepReviewActionBarStore'; +import type { FlowChatState, Session } from '../../types/flow-chat'; + +let flowChatState: FlowChatState; +const translate = (_key: string, options?: Record & { defaultValue?: string }) => ( + options?.defaultValue ?? _key +); + +vi.mock('react-i18next', () => ({ + initReactI18next: { + type: '3rdParty', + init: vi.fn(), + }, + useTranslation: () => ({ + t: translate, + }), +})); + +vi.mock('../modern/VirtualItemRenderer', () => ({ + VirtualItemRenderer: () =>
, +})); + +vi.mock('../modern/ProcessingIndicator', () => ({ + ProcessingIndicator: () =>
, +})); + +vi.mock('../modern/processingIndicatorVisibility', () => ({ + shouldReserveProcessingIndicatorSpace: () => false, + shouldShowProcessingIndicator: () => false, +})); + +vi.mock('../modern/useExploreGroupState', () => ({ + useExploreGroupState: () => ({ + exploreGroupStates: {}, + onExploreGroupToggle: vi.fn(), + onExpandGroup: vi.fn(), + onExpandAllInTurn: vi.fn(), + onCollapseGroup: vi.fn(), + }), +})); + +vi.mock('@/flow_chat', () => ({ + ScrollToBottomButton: () =>
, +})); + +vi.mock('./DeepReviewActionBar', () => ({ + ReviewActionBar: () =>
, +})); + +vi.mock('@/component-library', () => ({ + IconButton: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( + + ), +})); + +vi.mock('@/shared/services/FileTabManager', () => ({ + fileTabManager: { + openFile: vi.fn(), + }, +})); + +vi.mock('@/shared/utils/tabUtils', () => ({ + createTab: vi.fn(), +})); + +vi.mock('@/infrastructure/api', () => ({ + agentAPI: { + cancelSession: vi.fn(), + }, +})); + +vi.mock('@/infrastructure/event-bus', () => ({ + globalEventBus: { + emit: vi.fn(), + }, +})); + +vi.mock('@/shared/notification-system', () => ({ + notificationService: { + error: vi.fn(), + }, +})); + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), +})); + +vi.mock('../../store/FlowChatStore', () => ({ + FlowChatStore: { + getInstance: () => ({ + getState: () => flowChatState, + subscribe: () => () => {}, + loadSessionHistory: vi.fn(), + }), + }, + flowChatStore: { + getState: () => flowChatState, + subscribe: () => () => {}, + loadSessionHistory: vi.fn(), + }, +})); + +vi.mock('../../store/modernFlowChatStore', () => ({ + sessionToVirtualItems: () => [], +})); + +vi.mock('../../utils/reviewSessionStop', () => ({ + settleStoppedReviewSessionState: vi.fn(), +})); + +vi.mock('../../services/ReviewActionBarPersistenceService', () => ({ + loadPersistedReviewState: vi.fn(() => Promise.resolve(null)), +})); + +function createReviewSession(): Session { + return { + sessionId: 'deep-review-child', + title: 'Deep review', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'deep-review-child', + userMessage: { id: 'user-1', content: 'review', timestamp: 1 }, + modelRounds: [{ + id: 'round-1', + index: 0, + isStreaming: false, + isComplete: true, + status: 'completed', + startTime: 1, + items: [{ + id: 'review-result', + type: 'tool', + timestamp: 2, + status: 'completed', + toolName: 'submit_code_review', + toolCall: { id: 'tool-1', input: {} }, + toolResult: { + success: true, + result: JSON.stringify({ + summary: { + overall_assessment: 'Looks safe.', + risk_level: 'low', + recommended_action: 'approve', + }, + issues: [], + positive_points: ['No risky changes found.'], + review_mode: 'deep', + remediation_plan: [], + }), + }, + }], + }], + status: 'completed', + startTime: 1, + }], + status: 'idle', + config: {}, + createdAt: 1, + lastActiveAt: 1, + error: null, + sessionKind: 'deep_review', + parentSessionId: 'parent-session', + workspacePath: 'D:/workspace/project', + } as Session; +} + +describe('BtwSessionPanel review action bar integration', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + useReviewActionBarStore.getState().reset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + const childSession = createReviewSession(); + flowChatState = { + sessions: new Map([ + ['deep-review-child', childSession], + ['parent-session', { + sessionId: 'parent-session', + title: 'Parent', + dialogTurns: [], + status: 'idle', + config: {}, + createdAt: 1, + lastActiveAt: 1, + error: null, + } as Session], + ]), + activeSessionId: 'deep-review-child', + } as FlowChatState; + + globalThis.ResizeObserver = class { + observe() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + useReviewActionBarStore.getState().reset(); + }); + + it('shows the completed Deep Review action bar even when the report has no remediation items', async () => { + await act(async () => { + root.render( + , + ); + }); + + expect(useReviewActionBarStore.getState()).toMatchObject({ + childSessionId: 'deep-review-child', + phase: 'review_completed', + }); + expect(useReviewActionBarStore.getState().remediationItems).toEqual([]); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index 10810dd0b..727bebd3f 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -482,15 +482,13 @@ export const BtwSessionPanel: React.FC = ({ return; } - if (hasRemediationPlan) { - store.showActionBar({ - childSessionId, - parentSessionId: parentSessionId ?? null, - reviewData: latestReviewData, - reviewMode, - phase: 'review_completed', - }); - } + store.showActionBar({ + childSessionId, + parentSessionId: parentSessionId ?? null, + reviewData: latestReviewData, + reviewMode, + phase: 'review_completed', + }); }, [childSession, childSessionId, parentSessionId, isReviewSession, isDeepReview]); // Restore persisted review action state on mount diff --git a/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.test.ts b/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.test.ts index 3fc92ee8c..6bd83af9f 100644 --- a/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.test.ts +++ b/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.test.ts @@ -347,6 +347,45 @@ describe('deepReviewActionBarStore', () => { expect(bar().capacityQueueState).toBeNull(); expect(bar().phase).toBe('idle'); }); + + it('keeps the capacity queue visible when a terminal reviewer event reports more queued reviewers', () => { + bar().showCapacityQueueBar({ + childSessionId: 'child-1', + parentSessionId: 'parent-1', + capacityQueueState: { + toolId: 'task-security', + subagentType: 'ReviewSecurity', + status: 'queued_for_capacity', + reason: 'local_concurrency_cap', + queuedReviewerCount: 1, + waitingReviewers: [{ + toolId: 'task-security', + subagentType: 'ReviewSecurity', + displayName: 'Security reviewer', + status: 'queued_for_capacity', + reason: 'local_concurrency_cap', + }], + }, + }); + + bar().applyCapacityQueueState({ + toolId: 'task-security', + subagentType: 'ReviewSecurity', + status: 'running', + reason: 'launch_batch_blocked', + queuedReviewerCount: 1, + activeReviewerCount: 1, + waitingReviewers: [], + }); + + expect(bar().capacityQueueState).toMatchObject({ + status: 'queued_for_capacity', + queuedReviewerCount: 1, + activeReviewerCount: 1, + }); + expect(bar().capacityQueueState?.waitingReviewers).toEqual([]); + expect(bar().phase).toBe('review_waiting_capacity'); + }); }); describe('toggleRemediation with completed items', () => { diff --git a/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts b/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts index a1a978c84..791038af8 100644 --- a/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts +++ b/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts @@ -272,6 +272,16 @@ function mergeCapacityQueueState( const waitingReviewers = [...reviewerMap.values()]; if (waitingReviewers.length === 0) { + if (isTerminalQueueStatus(incoming.status) && incoming.queuedReviewerCount > 0) { + return { + ...incoming, + status: current?.status === 'paused_by_user' ? 'paused_by_user' : 'queued_for_capacity', + queuedReviewerCount: incoming.queuedReviewerCount, + optionalReviewerCount: incoming.optionalReviewerCount ?? 0, + waitingReviewers: [], + }; + } + return null; } diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx new file mode 100644 index 000000000..888adbf15 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import AcpAgentsConfig from './AcpAgentsConfig'; + +const loadJsonConfigMock = vi.hoisted(() => vi.fn()); +const getClientsMock = vi.hoisted(() => vi.fn()); +const probeClientRequirementsMock = vi.hoisted(() => vi.fn()); +const notifyErrorMock = vi.hoisted(() => vi.fn()); +const notifySuccessMock = vi.hoisted(() => vi.fn()); +const translate = (_key: string, options?: Record & { defaultValue?: string }) => ( + options?.defaultValue ?? _key +); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: translate, + }), +})); + +vi.mock('@/component-library', () => ({ + Button: ({ + children, + disabled, + isLoading, + onClick, + }: { + children: React.ReactNode; + disabled?: boolean; + isLoading?: boolean; + onClick?: () => void; + }) => ( + + ), + Input: ({ + value, + onChange, + placeholder, + }: { + value?: string; + onChange?: React.ChangeEventHandler; + placeholder?: string; + }) => , + Select: ({ + value, + onChange, + options, + }: { + value?: string; + onChange?: (value: string) => void; + options?: Array<{ value: string; label: string }>; + }) => ( + + ), + Textarea: React.forwardRef>( + (props, ref) =>